SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
20.1 KB · 347 lines python
Raw Blame History
1"""Ingestion guards: co-registrant filings (shared accessions), poller termination (form filter, Redis seen-set,2bounded retries), failure samples, conditional statement rewrite, coverage dictionary, schema migrations."""3from __future__ import annotations45import types6from datetime import date78import httpx9import pytest10from sqlalchemy import delete, select1112PARENT, SUB = 65984, 66901            # Entergy Corp + Entergy Arkansas: one 10-Q/8-K, two registrants13SHARED = "0000065984-26-000283"14OTHER = "0000065984-26-000290"151617def _atom(entries: list[tuple[str, str, int, str]]) -> str:18    items = []19    for form, name, cik, accn in entries:20        nodash = accn.replace("-", "")21        items.append(f"""<entry>22<title>{form} - {name} ({cik:010d}) (Filer)</title>23<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/{cik}/{nodash}/{accn}-index.htm"/>24<updated>2026-09-04T17:27:57-04:00</updated>25<category scheme="https://www.sec.gov/" label="form type" term="{form}"/>26<id>urn:tag:sec.gov,2008:accession-number={accn}</id>27</entry>""")28    return ('<?xml version="1.0" encoding="ISO-8859-1" ?>\n<feed xmlns="http://www.w3.org/2005/Atom">\n<title>Latest Filings</title>\n'29            + "\n".join(items) + "\n</feed>")303132class FakeClient:33    """Minimal EdgarClient stand-in for poll_new_filings: canned Atom feed, optional daily index."""3435    def __init__(self, atom: str, index: str | None = None):36        self.atom, self.index = atom, index37        self.stats = types.SimpleNamespace(requests=0)38        self.index_calls: list[date] = []3940    def atom_current(self, form: str = "") -> str:41        self.stats.requests += 142        return self.atom4344    def daily_index(self, d: date) -> str | None:45        self.stats.requests += 146        self.index_calls.append(d)47        return self.index484950@pytest.fixture51def cofilers(fundamentals_data):52    """Two temporary tracked companies sharing accessions; everything they touch is removed afterwards."""53    from core.db import session54    from fundamentals import ingest55    from fundamentals.models import EdgarCompany, EdgarFiling, FundIngestState56    from stream.broker import get_redis57    r = get_redis()58    r.delete(ingest.SEEN_KEY, ingest.FAILED_KEY)59    with session() as s:60        before = s.get(FundIngestState, "incremental")61        snapshot = {k: getattr(before, k) for k in ("failures", "failure_samples")} if before else None62        s.add(EdgarCompany(cik=PARENT, ticker="ETR", tickers=["ETR"], name="Entergy Corp", status="active", ticker_history=[]))63        s.add(EdgarCompany(cik=SUB, ticker="EAL", tickers=["EAL"], name="Entergy Arkansas", status="active", ticker_history=[]))64    try:65        yield {"parent": PARENT, "sub": SUB, "redis": r}66    finally:67        from fundamentals.models import FundCoverage, fund_statements68        with session() as s:69            s.execute(delete(EdgarFiling).where(EdgarFiling.cik.in_([PARENT, SUB])))70            s.execute(delete(EdgarCompany).where(EdgarCompany.cik.in_([PARENT, SUB])))71            s.execute(delete(FundCoverage).where(FundCoverage.cik.in_([PARENT, SUB])))72            s.execute(delete(fund_statements).where(fund_statements.c.cik.in_([PARENT, SUB])))73            st = s.get(FundIngestState, "incremental")74            if st is not None and snapshot is not None:75                st.failures, st.failure_samples = snapshot["failures"], snapshot["failure_samples"]76        r.delete(ingest.SEEN_KEY, ingest.FAILED_KEY)777879def _stub(monkeypatch, outcome):80    """Replace ingest_company with a recorder; `outcome(cik)` → error string or None."""81    from fundamentals import ingest82    calls: list[int] = []8384    def fake(client, cik, **kw):85        calls.append(int(cik))86        res = ingest.IngestResult(cik=int(cik), ticker=str(cik))87        res.error = outcome(int(cik))88        res.changed = res.error is None89        return res90    monkeypatch.setattr(ingest, "ingest_company", fake)91    return calls929394# ------------------------------------------------------------------------------------- co-registrants95def test_shared_accession_is_one_row_per_registrant(cofilers):96    import pandas as pd9798    from fundamentals import ingest99    from fundamentals import normalize as N100    from fundamentals import service as S101    shared = N.Filing(accn=SHARED, form="10-Q", filed=date(2026, 7, 31), report_date=date(2026, 6, 30), primary_doc="etr-20260630.htm")102    only_parent = N.Filing(accn=OTHER, form="8-K", filed=date(2026, 8, 1), report_date=date(2026, 8, 1))103    untracked = N.Filing(accn="0000065984-26-000299", form="S-8", filed=date(2026, 8, 2))104    assert ingest._upsert_filings(PARENT, {SHARED: shared, OTHER: only_parent, "x": untracked}, pd.DataFrame()) == 2105    assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 1          # no IntegrityError any more106    assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 0          # idempotent107    pairs = {(PARENT, SHARED), (SUB, SHARED), (PARENT, OTHER), (SUB, OTHER)}108    assert ingest._known_pairs(pairs) == {(PARENT, SHARED), (SUB, SHARED), (PARENT, OTHER)}109    assert ingest._known_accns(SUB) == {SHARED}110    # both companies list the shared filing with their own EDGAR links111    for tk, cik in (("ETR", PARENT), ("EAL", SUB)):112        df, meta = S.filings(tk, form=None, date_from=None, date_to=None)113        row = df[df["accn"] == SHARED].iloc[0]114        assert meta["cik"] == cik and row["primary_doc_url"] == f"https://www.sec.gov/Archives/edgar/data/{cik}/000006598426000283/etr-20260630.htm"115    assert len(S.filings("ETR", form="10-Q,8-K", date_from=None, date_to=None)[0]) == 2116    assert len(S.filings("EAL", form=None, date_from=None, date_to=None)[0]) == 1117118119def test_poll_ingests_each_coregistrant_once(cofilers, monkeypatch):120    from fundamentals import ingest121    calls = _stub(monkeypatch, lambda cik: None)122    atom = _atom([("8-K", "Entergy Corp", PARENT, SHARED), ("8-K", "Entergy Arkansas", SUB, SHARED),123                  ("S-8", "Entergy Corp", PARENT, "0000065984-26-000299"),        # untracked form: ignored124                  ("8-K", "Nobody Inc", 999999999, "0000999999-26-000001")])      # not a tracked CIK125    client = FakeClient(atom, index=None)126    s1 = ingest.poll_new_filings(client, forms=("8-K",))127    assert s1["seen"] == 2 and s1["new"] == 2 and s1["affected_ciks"] == [PARENT, SUB] and s1["index_days"] == []128    assert sorted(calls) == [PARENT, SUB] and s1["rows_changed"] == 2 and s1["failures"] == []129    assert all(d.weekday() < 5 for d in client.index_calls) and len(client.index_calls) == 2130    # the stub recorded nothing in edgar_filings: without the Redis guard the same pairs would be "new" for ever131    s2 = ingest.poll_new_filings(client, forms=("8-K",))132    assert s2["seen"] == 2 and s2["new"] == 0 and s2["affected_ciks"] == [] and len(calls) == 2133    assert cofilers["redis"].scard(ingest.SEEN_KEY) == 2 and cofilers["redis"].ttl(ingest.SEEN_KEY) > 6 * 86_400134135136def test_amendment_in_the_8k_feed_is_tracked(cofilers, monkeypatch):137    """The `type=8-K` Atom feed also returns 8-K/A entries — they must land in edgar_filings (Genasys loop)."""138    from fundamentals import ingest139    from fundamentals.edgar_client import TRACKED_FORMS140    assert {"8-K/A", "10-K/A", "10-Q/A", "10-KT", "10-QT", "20-F/A", "6-K/A"} <= set(TRACKED_FORMS)141    calls = _stub(monkeypatch, lambda cik: None)142    client = FakeClient(_atom([("8-K/A", "Entergy Corp", PARENT, OTHER)]))143    s1 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False)144    assert s1["new"] == 1 and calls == [PARENT]145146147def test_transient_failures_are_retried_then_abandoned(cofilers, monkeypatch):148    from core.db import session149    from fundamentals import ingest150    from fundamentals.models import FundIngestState151    calls = _stub(monkeypatch, lambda cik: "HTTPStatusError: 503 Service Unavailable" if cik == PARENT else None)152    client = FakeClient(_atom([("8-K", "Entergy Corp", PARENT, SHARED), ("8-K", "Entergy Arkansas", SUB, SHARED)]))153    with session() as s:154        st = s.get(FundIngestState, "incremental")155        failures0 = (st.failures or 0) if st else 0156    summaries = [ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False) for _ in range(ingest.MAX_ATTEMPTS + 1)]157    # the subsidiary succeeded once; the parent was retried MAX_ATTEMPTS times then marked seen158    assert calls.count(SUB) == 1 and calls.count(PARENT) == ingest.MAX_ATTEMPTS159    assert [s["new"] for s in summaries] == [2, 1, 1, 0]160    assert summaries[0]["failures"] == ["HTTPStatusError: 503 Service Unavailable"]161    with session() as s:162        st = s.get(FundIngestState, "incremental")163        assert st.failures == failures0 + ingest.MAX_ATTEMPTS164        # samples survive the clean 4th cycle and carry the CIK165        assert st.failure_samples and all(x.startswith(f"cik={PARENT} HTTPStatusError") for x in st.failure_samples[-ingest.MAX_ATTEMPTS:])166    assert cofilers["redis"].hlen(ingest.FAILED_KEY) == 0        # cleared once marked seen167168169def test_no_facts_is_permanent_not_a_failure(cofilers, monkeypatch):170    from core.db import session171    from fundamentals import ingest172    from fundamentals.models import FundIngestState173    calls = _stub(monkeypatch, lambda cik: ingest.NO_FACTS)174    client = FakeClient(_atom([("8-K", "Entergy Corp", PARENT, OTHER)]))175    with session() as s:176        st = s.get(FundIngestState, "incremental")177        failures0 = (st.failures or 0) if st else 0178    s1 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False)179    s2 = ingest.poll_new_filings(client, forms=("8-K",), include_daily_index=False)180    assert s1["new"] == 1 and s1["no_facts"] == [PARENT] and s1["failures"] == [] and s2["new"] == 0 and calls == [PARENT]181    with session() as s:182        assert (s.get(FundIngestState, "incremental").failures or 0) == failures0183184185def test_ingest_company_without_facts_still_records_filings(cofilers):186    """companyfacts 404 (funds, trusts…) must not leave the accessions unknown — the poller would loop on them."""187    from fundamentals import ingest188    sub = {"cik": PARENT, "name": "Entergy Corp", "fiscalYearEnd": "1231", "filings": {"recent": {189        "accessionNumber": [SHARED, OTHER], "form": ["10-Q", "8-K"], "filingDate": ["2026-07-31", "2026-08-01"],190        "reportDate": ["2026-06-30", "2026-08-01"], "primaryDocument": ["etr-20260630.htm", "etr-8k.htm"], "isXBRL": [1, 1]}}}191    res = ingest.ingest_company(None, PARENT, companyfacts={"cik": PARENT, "facts": {}}, submissions=sub)192    assert res.error is None or res.error == ingest.NO_FACTS193    no_facts_client = types.SimpleNamespace(companyfacts=lambda cik, refresh=False: None,194                                            submissions=lambda cik, refresh=False: {**sub, "cik": SUB})195    res = ingest.ingest_company(no_facts_client, SUB)196    assert res.error == ingest.NO_FACTS and not res.failed and res.filings == 2197    assert ingest._known_accns(SUB) == {SHARED, OTHER}198199200# ------------------------------------------------------------------------------------- daily index201def test_business_days_back():202    from fundamentals.ingest import business_days_back203    assert business_days_back(date(2026, 9, 6)) == [date(2026, 9, 4), date(2026, 9, 3)]      # Sunday → Fri, Thu204    assert business_days_back(date(2026, 9, 7)) == [date(2026, 9, 7), date(2026, 9, 4)]      # Monday → Mon, Fri205    assert business_days_back(date(2026, 9, 9), 3) == [date(2026, 9, 9), date(2026, 9, 8), date(2026, 9, 7)]206207208def test_daily_index_403_means_not_published(app, tmp_path):209    """www.sec.gov/Archives answers 403 for objects that do not exist (weekend indexes): no exception, no retry."""210    from fundamentals import edgar_client as ec211    hits: list[str] = []212213    def handler(request: httpx.Request) -> httpx.Response:214        hits.append(str(request.url))215        assert "contact@" in request.headers["user-agent"] and "gzip" in request.headers["accept-encoding"]216        if request.url.path.endswith("master.20260906.idx"):217            return httpx.Response(403, text="<Error><Code>AccessDenied</Code></Error>")218        return httpx.Response(200, text="CIK|Company Name|Form Type|Date Filed|File Name\n"219                                        "65984|ENTERGY CORP|8-K|20260904|edgar/data/65984/0000065984-26-000290.txt\n")220    client = ec.EdgarClient(rate=1000, raw_dir=tmp_path, http=httpx.Client(transport=httpx.MockTransport(handler), headers={221        "User-Agent": "HF Market Data (test, contact@spboucher.ai)", "Accept-Encoding": "gzip, deflate"}))222    assert client.daily_index(date(2026, 9, 6)) is None223    assert len(hits) == 1 and hits[0].endswith("/Archives/edgar/daily-index/2026/QTR3/master.20260906.idx")224    text = client.daily_index(date(2026, 9, 4))225    from fundamentals.ingest import parse_master_index226    assert parse_master_index(text) == [{"cik": 65984, "name": "ENTERGY CORP", "form": "8-K", "filed": "2026-09-04",227                                         "accn": "0000065984-26-000290"}]228    with pytest.raises(httpx.HTTPStatusError):229        client.get_text("https://www.sec.gov/Archives/edgar/daily-index/2026/QTR3/master.20260906.idx")230231232# ------------------------------------------------------------------------------------- statements / coverage233def test_replace_statements_only_rewrites_on_change(fundamentals_data):234    from core.db import session235    from fundamentals import ingest236    from fundamentals import service as S237    from fundamentals.models import fund_statements238    cik = 320193239    original = S._rows(cik)240    ids0 = sorted(r["id"] for r in original)241    assert original and all(isinstance(r["coverage"], dict) for r in original) and "coverage_id" not in original[0]242    assert ingest._replace_statements(cik, original) is False                      # identical → untouched243    with session() as s:244        assert sorted(s.scalars(select(fund_statements.c.id).where(fund_statements.c.cik == cik))) == ids0245    changed = [dict(r) for r in original]246    target = next(r for r in changed if r["statement"] == "income" and r.get("revenue") is not None)247    target["revenue"] = target["revenue"] + 1.0248    assert ingest._replace_statements(cik, changed) is True249    again = S._rows(cik)250    assert sorted(r["id"] for r in again) != ids0251    hit = next(r for r in again if r["accn"] == target["accn"] and r["statement"] == "income" and r["fiscal_quarter"] == target["fiscal_quarter"])252    assert hit["revenue"] == target["revenue"] and hit["coverage"] == target["coverage"]253    with session() as s:   # written through the dictionary: inline JSON is NULL, the reference is set254        rows = s.execute(select(fund_statements.c.coverage, fund_statements.c.coverage_id).where(fund_statements.c.cik == cik)).all()255    assert rows and all(c is None and cid is not None for c, cid in rows)256    assert ingest._replace_statements(cik, original) is True                       # restore for the other tests257    assert S._rows(cik)[0]["coverage"] == original[0]["coverage"]258259260def test_coverage_dictionary_interns_and_hydrates(app):261    from core.db import session262    from fundamentals import coverage_store as CS263    from fundamentals.models import FundCoverageBlob264    a = {"interest_expense": {"reason": "no_mapped_tag"}, "ebitda": {"computed": "operating_income + d_and_a"}}265    b = {"ebitda": {"computed": "operating_income + d_and_a"}, "interest_expense": {"reason": "no_mapped_tag"}}   # same, other order266    ids = CS.intern([a, b, {}, a])267    assert ids[0] == ids[1] == ids[3] and ids[2] != ids[0] and CS.digest(a) == CS.digest(b)268    CS.reset_cache_for_tests()269    assert CS.intern([b]) == [ids[0]]                                     # found in the table, not re-inserted270    with session() as s:271        n = s.scalar(select(FundCoverageBlob.id).where(FundCoverageBlob.sha1 == CS.digest(a)))272    assert n == ids[0]273    rows = [{"accn": "x", "coverage": None, "coverage_id": ids[0]},          # dictionary reference274            {"accn": "y", "coverage": {"revenue": {"tag": "us-gaap:Revenues"}}, "coverage_id": None},   # legacy inline275            {"accn": "z", "coverage": None, "coverage_id": None}]276    CS.hydrate(rows)277    assert rows[0]["coverage"] == a and rows[1]["coverage"] == {"revenue": {"tag": "us-gaap:Revenues"}} and rows[2]["coverage"] == {}278    assert all("coverage_id" not in r for r in rows)279    assert CS.sha_of_ids({ids[0]}) == {ids[0]: CS.digest(a)}280281282def test_coverage_intern_survives_cache_eviction(app, monkeypatch):283    """Prod 2026-09-06: the 50 533rd distinct document raised KeyError because _remember() wiped the cache284    (CACHE_MAX) in the middle of intern(); ids must be resolved locally, not through the cache."""285    from fundamentals import coverage_store as CS286    CS.reset_cache_for_tests()287    monkeypatch.setattr(CS, "CACHE_MAX", 5)288    docs = [{"k%d" % i: {"reason": "r%d" % i}} for i in range(20)] + [{"k0": {"reason": "r0"}}]289    ids = CS.intern(docs)290    assert len(ids) == 21 and len(set(ids[:20])) == 20 and ids[20] == ids[0]291    assert CS.intern(docs) == ids                                          # second pass: same ids, no KeyError292293294def test_failure_samples_append_and_cap(app):295    from core.db import session296    from fundamentals import ingest297    from fundamentals.models import FundIngestState298    key = "test_samples"299    try:300        ingest._set_state(key, failures_add=2, failure_samples_add=[f"e{i}" for i in range(12)])301        ingest._set_state(key, last_run_at=None)                          # a clean cycle keeps the evidence302        with session() as s:303            st = s.get(FundIngestState, key)304            assert st.failures == 2 and st.failure_samples == [f"e{i}" for i in range(2, 12)]305        ingest._set_state(key, failure_samples=[])                        # explicit replace still possible306        with session() as s:307            assert s.get(FundIngestState, key).failure_samples == []308    finally:309        with session() as s:310            s.execute(delete(FundIngestState).where(FundIngestState.key == key))311312313# ------------------------------------------------------------------------------------- migrations314def test_migrate_filings_pk_from_legacy_table(tmp_path):315    from sqlalchemy import create_engine, text316317    from fundamentals import migrations318    eng = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")319    with eng.begin() as con:320        con.execute(text("""CREATE TABLE edgar_filings (accn VARCHAR(24) NOT NULL, cik INTEGER NOT NULL, form VARCHAR(16) NOT NULL,321                            filed_date DATE NOT NULL, period_of_report DATE, primary_doc VARCHAR(512), is_amendment BOOLEAN NOT NULL,322                            is_xbrl BOOLEAN NOT NULL, parsed_at DATETIME, PRIMARY KEY (accn))"""))323        con.execute(text("CREATE INDEX ix_edgar_filings_cik ON edgar_filings (cik)"))324        con.execute(text("INSERT INTO edgar_filings VALUES ('0000065984-26-000283', 65984, '10-Q', '2026-07-31', '2026-06-30', NULL, 0, 1, NULL)"))325        con.execute(text("INSERT INTO edgar_filings VALUES ('0000320193-24-000069', 320193, '10-Q', '2024-05-03', '2024-03-30', NULL, 0, 1, NULL)"))326        assert migrations.filings_pk_is_composite(con) is False327        assert migrations.migrate_filings_pk(con) == 2328        assert migrations.filings_pk_is_composite(con) is True329        # the subsidiary's copy of the shared accession now fits330        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)"))331        assert con.execute(text("SELECT count(*) FROM edgar_filings WHERE accn='0000065984-26-000283'")).scalar() == 2332        names = {r[0] for r in con.execute(text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='edgar_filings'"))}333        assert {"ix_edgar_filings_accn", "ix_edgar_filings_cik_filed", "ix_edgar_filings_form", "ix_edgar_filings_filed_date"} <= names334        assert migrations.migrate_filings_pk(con) == 3                     # idempotent rerun keeps every row335336337def test_startup_schema_is_idempotent(fundamentals_data):338    from core.db import engine339    from fundamentals import migrations340    assert migrations.ensure_filings_schema() == "ok"341    assert migrations.ensure_statements_schema() is False342    assert migrations.ensure_auto_vacuum() is False                        # already switched when the test DB was young343    with engine.connect() as con:344        from sqlalchemy import text345        assert int(con.execute(text("PRAGMA auto_vacuum")).scalar()) == 2346    migrations.incremental_vacuum()347