"""Ingestion guards: co-registrant filings (shared accessions), poller termination (form filter, Redis seen-set, bounded retries), failure samples, conditional statement rewrite, coverage dictionary, schema migrations.""" from __future__ import annotations import types from datetime import date import httpx import pytest from sqlalchemy import delete, select PARENT, SUB = 65984, 66901 # Entergy Corp + Entergy Arkansas: one 10-Q/8-K, two registrants SHARED = "0000065984-26-000283" OTHER = "0000065984-26-000290" def _atom(entries: list[tuple[str, str, int, str]]) -> str: items = [] for form, name, cik, accn in entries: nodash = accn.replace("-", "") items.append(f""" {form} - {name} ({cik:010d}) (Filer) 2026-09-04T17:27:57-04:00 urn:tag:sec.gov,2008:accession-number={accn} """) return ('\n\nLatest Filings\n' + "\n".join(items) + "\n") class FakeClient: """Minimal EdgarClient stand-in for poll_new_filings: canned Atom feed, optional daily index.""" def __init__(self, atom: str, index: str | None = None): self.atom, self.index = atom, index self.stats = types.SimpleNamespace(requests=0) self.index_calls: list[date] = [] def atom_current(self, form: str = "") -> str: self.stats.requests += 1 return self.atom def daily_index(self, d: date) -> str | None: self.stats.requests += 1 self.index_calls.append(d) return self.index @pytest.fixture def cofilers(fundamentals_data): """Two temporary tracked companies sharing accessions; everything they touch is removed afterwards.""" from core.db import session from fundamentals import ingest from fundamentals.models import EdgarCompany, EdgarFiling, FundIngestState from stream.broker import get_redis r = get_redis() r.delete(ingest.SEEN_KEY, ingest.FAILED_KEY) with session() as s: before = s.get(FundIngestState, "incremental") snapshot = {k: getattr(before, k) for k in ("failures", "failure_samples")} if before else None s.add(EdgarCompany(cik=PARENT, ticker="ETR", tickers=["ETR"], name="Entergy Corp", status="active", ticker_history=[])) s.add(EdgarCompany(cik=SUB, ticker="EAL", tickers=["EAL"], name="Entergy Arkansas", status="active", ticker_history=[])) try: yield {"parent": PARENT, "sub": SUB, "redis": r} finally: from fundamentals.models import FundCoverage, fund_statements with session() as s: s.execute(delete(EdgarFiling).where(EdgarFiling.cik.in_([PARENT, SUB]))) s.execute(delete(EdgarCompany).where(EdgarCompany.cik.in_([PARENT, SUB]))) s.execute(delete(FundCoverage).where(FundCoverage.cik.in_([PARENT, SUB]))) s.execute(delete(fund_statements).where(fund_statements.c.cik.in_([PARENT, SUB]))) st = s.get(FundIngestState, "incremental") if st is not None and snapshot is not None: st.failures, st.failure_samples = snapshot["failures"], snapshot["failure_samples"] r.delete(ingest.SEEN_KEY, ingest.FAILED_KEY) def _stub(monkeypatch, outcome): """Replace ingest_company with a recorder; `outcome(cik)` → error string or None.""" from fundamentals import ingest calls: list[int] = [] def fake(client, cik, **kw): calls.append(int(cik)) res = ingest.IngestResult(cik=int(cik), ticker=str(cik)) res.error = outcome(int(cik)) res.changed = res.error is None return res monkeypatch.setattr(ingest, "ingest_company", fake) return calls # ------------------------------------------------------------------------------------- co-registrants def test_shared_accession_is_one_row_per_registrant(cofilers): import pandas as pd from fundamentals import ingest from fundamentals import normalize as N from fundamentals import service as S shared = N.Filing(accn=SHARED, form="10-Q", filed=date(2026, 7, 31), report_date=date(2026, 6, 30), primary_doc="etr-20260630.htm") only_parent = N.Filing(accn=OTHER, form="8-K", filed=date(2026, 8, 1), report_date=date(2026, 8, 1)) untracked = N.Filing(accn="0000065984-26-000299", form="S-8", filed=date(2026, 8, 2)) assert ingest._upsert_filings(PARENT, {SHARED: shared, OTHER: only_parent, "x": untracked}, pd.DataFrame()) == 2 assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 1 # no IntegrityError any more assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 0 # idempotent pairs = {(PARENT, SHARED), (SUB, SHARED), (PARENT, OTHER), (SUB, OTHER)} assert ingest._known_pairs(pairs) == {(PARENT, SHARED), (SUB, SHARED), (PARENT, OTHER)} assert ingest._known_accns(SUB) == {SHARED} # both companies list the shared filing with their own EDGAR links for tk, cik in (("ETR", PARENT), ("EAL", SUB)): df, meta = S.filings(tk, form=None, date_from=None, date_to=None) row = df[df["accn"] == SHARED].iloc[0] assert meta["cik"] == cik and row["primary_doc_url"] == f"https://www.sec.gov/Archives/edgar/data/{cik}/000006598426000283/etr-20260630.htm" assert len(S.filings("ETR", form="10-Q,8-K", date_from=None, date_to=None)[0]) == 2 assert len(S.filings("EAL", form=None, date_from=None, date_to=None)[0]) == 1 def test_poll_ingests_each_coregistrant_once(cofilers, monkeypatch): from fundamentals import ingest calls = _stub(monkeypatch, lambda cik: None) atom = _atom([("8-K", "Entergy Corp", PARENT, SHARED), ("8-K", "Entergy Arkansas", SUB, SHARED), ("S-8", "Entergy Corp", PARENT, "0000065984-26-000299"), # untracked form: ignored ("8-K", "Nobody Inc", 999999999, "0000999999-26-000001")]) # not a tracked CIK client = FakeClient(atom, index=None) s1 = ingest.poll_new_filings(client, forms=("8-K",)) assert s1["seen"] == 2 and s1["new"] == 2 and s1["affected_ciks"] == [PARENT, SUB] and s1["index_days"] == [] assert sorted(calls) == [PARENT, SUB] and s1["rows_changed"] == 2 and s1["failures"] == [] assert all(d.weekday() < 5 for d in client.index_calls) and len(client.index_calls) == 2 # the stub recorded nothing in edgar_filings: without the Redis guard the same pairs would be "new" for ever s2 = ingest.poll_new_filings(client, forms=("8-K",)) assert s2["seen"] == 2 and s2["new"] == 0 and s2["affected_ciks"] == [] and len(calls) == 2 assert cofilers["redis"].scard(ingest.SEEN_KEY) == 2 and cofilers["redis"].ttl(ingest.SEEN_KEY) > 6 * 86_400 def test_amendment_in_the_8k_feed_is_tracked(cofilers, monkeypatch): """The `type=8-K` Atom feed also returns 8-K/A entries — they must land in edgar_filings (Genasys loop).""" from fundamentals import ingest from fundamentals.edgar_client import TRACKED_FORMS assert {"8-K/A", "10-K/A", "10-Q/A", "10-KT", "10-QT", "20-F/A", "6-K/A"} <= set(TRACKED_FORMS) calls = _stub(monkeypatch, lambda cik: None) client = FakeClient(_atom([("8-K/A", "Entergy Corp", PARENT, OTHER)])) s1 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False) assert s1["new"] == 1 and calls == [PARENT] def test_transient_failures_are_retried_then_abandoned(cofilers, monkeypatch): from core.db import session from fundamentals import ingest from fundamentals.models import FundIngestState calls = _stub(monkeypatch, lambda cik: "HTTPStatusError: 503 Service Unavailable" if cik == PARENT else None) client = FakeClient(_atom([("8-K", "Entergy Corp", PARENT, SHARED), ("8-K", "Entergy Arkansas", SUB, SHARED)])) with session() as s: st = s.get(FundIngestState, "incremental") failures0 = (st.failures or 0) if st else 0 summaries = [ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False) for _ in range(ingest.MAX_ATTEMPTS + 1)] # the subsidiary succeeded once; the parent was retried MAX_ATTEMPTS times then marked seen assert calls.count(SUB) == 1 and calls.count(PARENT) == ingest.MAX_ATTEMPTS assert [s["new"] for s in summaries] == [2, 1, 1, 0] assert summaries[0]["failures"] == ["HTTPStatusError: 503 Service Unavailable"] with session() as s: st = s.get(FundIngestState, "incremental") assert st.failures == failures0 + ingest.MAX_ATTEMPTS # samples survive the clean 4th cycle and carry the CIK assert st.failure_samples and all(x.startswith(f"cik={PARENT} HTTPStatusError") for x in st.failure_samples[-ingest.MAX_ATTEMPTS:]) assert cofilers["redis"].hlen(ingest.FAILED_KEY) == 0 # cleared once marked seen def test_no_facts_is_permanent_not_a_failure(cofilers, monkeypatch): from core.db import session from fundamentals import ingest from fundamentals.models import FundIngestState calls = _stub(monkeypatch, lambda cik: ingest.NO_FACTS) client = FakeClient(_atom([("8-K", "Entergy Corp", PARENT, OTHER)])) with session() as s: st = s.get(FundIngestState, "incremental") failures0 = (st.failures or 0) if st else 0 s1 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False) s2 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False) assert s1["new"] == 1 and s1["no_facts"] == [PARENT] and s1["failures"] == [] and s2["new"] == 0 and calls == [PARENT] with session() as s: assert (s.get(FundIngestState, "incremental").failures or 0) == failures0 def test_ingest_company_without_facts_still_records_filings(cofilers): """companyfacts 404 (funds, trusts…) must not leave the accessions unknown — the poller would loop on them.""" from fundamentals import ingest sub = {"cik": PARENT, "name": "Entergy Corp", "fiscalYearEnd": "1231", "filings": {"recent": { "accessionNumber": [SHARED, OTHER], "form": ["10-Q", "8-K"], "filingDate": ["2026-07-31", "2026-08-01"], "reportDate": ["2026-06-30", "2026-08-01"], "primaryDocument": ["etr-20260630.htm", "etr-8k.htm"], "isXBRL": [1, 1]}}} res = ingest.ingest_company(None, PARENT, companyfacts={"cik": PARENT, "facts": {}}, submissions=sub) assert res.error is None or res.error == ingest.NO_FACTS no_facts_client = types.SimpleNamespace(companyfacts=lambda cik, refresh=False: None, submissions=lambda cik, refresh=False: {**sub, "cik": SUB}) res = ingest.ingest_company(no_facts_client, SUB) assert res.error == ingest.NO_FACTS and not res.failed and res.filings == 2 assert ingest._known_accns(SUB) == {SHARED, OTHER} # ------------------------------------------------------------------------------------- daily index def test_business_days_back(): from fundamentals.ingest import business_days_back assert business_days_back(date(2026, 9, 6)) == [date(2026, 9, 4), date(2026, 9, 3)] # Sunday → Fri, Thu assert business_days_back(date(2026, 9, 7)) == [date(2026, 9, 7), date(2026, 9, 4)] # Monday → Mon, Fri assert business_days_back(date(2026, 9, 9), 3) == [date(2026, 9, 9), date(2026, 9, 8), date(2026, 9, 7)] def test_daily_index_403_means_not_published(app, tmp_path): """www.sec.gov/Archives answers 403 for objects that do not exist (weekend indexes): no exception, no retry.""" from fundamentals import edgar_client as ec hits: list[str] = [] def handler(request: httpx.Request) -> httpx.Response: hits.append(str(request.url)) assert "contact@" in request.headers["user-agent"] and "gzip" in request.headers["accept-encoding"] if request.url.path.endswith("master.20260906.idx"): return httpx.Response(403, text="AccessDenied") return httpx.Response(200, text="CIK|Company Name|Form Type|Date Filed|File Name\n" "65984|ENTERGY CORP|8-K|20260904|edgar/data/65984/0000065984-26-000290.txt\n") client = ec.EdgarClient(rate=1000, raw_dir=tmp_path, http=httpx.Client(transport=httpx.MockTransport(handler), headers={ "User-Agent": "HF Market Data (test, contact@spboucher.ai)", "Accept-Encoding": "gzip, deflate"})) assert client.daily_index(date(2026, 9, 6)) is None assert len(hits) == 1 and hits[0].endswith("/Archives/edgar/daily-index/2026/QTR3/master.20260906.idx") text = client.daily_index(date(2026, 9, 4)) from fundamentals.ingest import parse_master_index assert parse_master_index(text) == [{"cik": 65984, "name": "ENTERGY CORP", "form": "8-K", "filed": "2026-09-04", "accn": "0000065984-26-000290"}] with pytest.raises(httpx.HTTPStatusError): client.get_text("https://www.sec.gov/Archives/edgar/daily-index/2026/QTR3/master.20260906.idx") # ------------------------------------------------------------------------------------- statements / coverage def test_replace_statements_only_rewrites_on_change(fundamentals_data): from core.db import session from fundamentals import ingest from fundamentals import service as S from fundamentals.models import fund_statements cik = 320193 original = S._rows(cik) ids0 = sorted(r["id"] for r in original) assert original and all(isinstance(r["coverage"], dict) for r in original) and "coverage_id" not in original[0] assert ingest._replace_statements(cik, original) is False # identical → untouched with session() as s: assert sorted(s.scalars(select(fund_statements.c.id).where(fund_statements.c.cik == cik))) == ids0 changed = [dict(r) for r in original] target = next(r for r in changed if r["statement"] == "income" and r.get("revenue") is not None) target["revenue"] = target["revenue"] + 1.0 assert ingest._replace_statements(cik, changed) is True again = S._rows(cik) assert sorted(r["id"] for r in again) != ids0 hit = next(r for r in again if r["accn"] == target["accn"] and r["statement"] == "income" and r["fiscal_quarter"] == target["fiscal_quarter"]) assert hit["revenue"] == target["revenue"] and hit["coverage"] == target["coverage"] with session() as s: # written through the dictionary: inline JSON is NULL, the reference is set rows = s.execute(select(fund_statements.c.coverage, fund_statements.c.coverage_id).where(fund_statements.c.cik == cik)).all() assert rows and all(c is None and cid is not None for c, cid in rows) assert ingest._replace_statements(cik, original) is True # restore for the other tests assert S._rows(cik)[0]["coverage"] == original[0]["coverage"] def test_coverage_dictionary_interns_and_hydrates(app): from core.db import session from fundamentals import coverage_store as CS from fundamentals.models import FundCoverageBlob a = {"interest_expense": {"reason": "no_mapped_tag"}, "ebitda": {"computed": "operating_income + d_and_a"}} b = {"ebitda": {"computed": "operating_income + d_and_a"}, "interest_expense": {"reason": "no_mapped_tag"}} # same, other order ids = CS.intern([a, b, {}, a]) assert ids[0] == ids[1] == ids[3] and ids[2] != ids[0] and CS.digest(a) == CS.digest(b) CS.reset_cache_for_tests() assert CS.intern([b]) == [ids[0]] # found in the table, not re-inserted with session() as s: n = s.scalar(select(FundCoverageBlob.id).where(FundCoverageBlob.sha1 == CS.digest(a))) assert n == ids[0] rows = [{"accn": "x", "coverage": None, "coverage_id": ids[0]}, # dictionary reference {"accn": "y", "coverage": {"revenue": {"tag": "us-gaap:Revenues"}}, "coverage_id": None}, # legacy inline {"accn": "z", "coverage": None, "coverage_id": None}] CS.hydrate(rows) assert rows[0]["coverage"] == a and rows[1]["coverage"] == {"revenue": {"tag": "us-gaap:Revenues"}} and rows[2]["coverage"] == {} assert all("coverage_id" not in r for r in rows) assert CS.sha_of_ids({ids[0]}) == {ids[0]: CS.digest(a)} def test_coverage_intern_survives_cache_eviction(app, monkeypatch): """Prod 2026-09-06: the 50 533rd distinct document raised KeyError because _remember() wiped the cache (CACHE_MAX) in the middle of intern(); ids must be resolved locally, not through the cache.""" from fundamentals import coverage_store as CS CS.reset_cache_for_tests() monkeypatch.setattr(CS, "CACHE_MAX", 5) docs = [{"k%d" % i: {"reason": "r%d" % i}} for i in range(20)] + [{"k0": {"reason": "r0"}}] ids = CS.intern(docs) assert len(ids) == 21 and len(set(ids[:20])) == 20 and ids[20] == ids[0] assert CS.intern(docs) == ids # second pass: same ids, no KeyError def test_failure_samples_append_and_cap(app): from core.db import session from fundamentals import ingest from fundamentals.models import FundIngestState key = "test_samples" try: ingest._set_state(key, failures_add=2, failure_samples_add=[f"e{i}" for i in range(12)]) ingest._set_state(key, last_run_at=None) # a clean cycle keeps the evidence with session() as s: st = s.get(FundIngestState, key) assert st.failures == 2 and st.failure_samples == [f"e{i}" for i in range(2, 12)] ingest._set_state(key, failure_samples=[]) # explicit replace still possible with session() as s: assert s.get(FundIngestState, key).failure_samples == [] finally: with session() as s: s.execute(delete(FundIngestState).where(FundIngestState.key == key)) # ------------------------------------------------------------------------------------- migrations def test_migrate_filings_pk_from_legacy_table(tmp_path): from sqlalchemy import create_engine, text from fundamentals import migrations eng = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") with eng.begin() as con: con.execute(text("""CREATE TABLE edgar_filings (accn VARCHAR(24) NOT NULL, cik INTEGER NOT NULL, form VARCHAR(16) NOT NULL, filed_date DATE NOT NULL, period_of_report DATE, primary_doc VARCHAR(512), is_amendment BOOLEAN NOT NULL, is_xbrl BOOLEAN NOT NULL, parsed_at DATETIME, PRIMARY KEY (accn))""")) con.execute(text("CREATE INDEX ix_edgar_filings_cik ON edgar_filings (cik)")) con.execute(text("INSERT INTO edgar_filings VALUES ('0000065984-26-000283', 65984, '10-Q', '2026-07-31', '2026-06-30', NULL, 0, 1, NULL)")) con.execute(text("INSERT INTO edgar_filings VALUES ('0000320193-24-000069', 320193, '10-Q', '2024-05-03', '2024-03-30', NULL, 0, 1, NULL)")) assert migrations.filings_pk_is_composite(con) is False assert migrations.migrate_filings_pk(con) == 2 assert migrations.filings_pk_is_composite(con) is True # the subsidiary's copy of the shared accession now fits con.execute(text("INSERT INTO edgar_filings (cik, accn, form, filed_date, is_amendment, is_xbrl) VALUES (66901, '0000065984-26-000283', '10-Q', '2026-07-31', 0, 1)")) assert con.execute(text("SELECT count(*) FROM edgar_filings WHERE accn='0000065984-26-000283'")).scalar() == 2 names = {r[0] for r in con.execute(text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='edgar_filings'"))} assert {"ix_edgar_filings_accn", "ix_edgar_filings_cik_filed", "ix_edgar_filings_form", "ix_edgar_filings_filed_date"} <= names assert migrations.migrate_filings_pk(con) == 3 # idempotent rerun keeps every row def test_startup_schema_is_idempotent(fundamentals_data): from core.db import engine from fundamentals import migrations assert migrations.ensure_filings_schema() == "ok" assert migrations.ensure_statements_schema() is False assert migrations.ensure_auto_vacuum() is False # already switched when the test DB was young with engine.connect() as con: from sqlalchemy import text assert int(con.execute(text("PRAGMA auto_vacuum")).scalar()) == 2 migrations.incremental_vacuum()