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%

fundamentals: co-déclarants (clé (cik, accn)), poller borné, dictionnaire de couverture

E1 — edgar_filings : clé primaire composite (cik, accn) + index sur accn. Un numéro
d'accession est partagé par les co-déclarants (famille Entergy 65984/66901/…) : la PK
sur accn seule faisait échouer tout _upsert_filings (IntegrityError, 18 entreprises
jamais ingérées). Migration idempotente (fundamentals/migrations.py) : inline au
démarrage si ≤ 300 k lignes, sinon avertissement + scripts/migrate_edgar_filings.py à
lancer avant le déploiement ; l'upsert passe par INSERT … ON CONFLICT DO NOTHING et
tolère l'ancien schéma pendant la transition. _known_pairs raisonne en paires (cik, accn)
sans balayer la table ; publish_filing_event produit un événement par co-déclarant.

E2 — poller incrémental : filtre des entrées Atom par TRACKED_FORMS (le flux type=8-K
renvoie des 8-K/A : Genasys ré-ingéré à chaque cycle pendant 41 h) ; TRACKED_FORMS
étendu aux amendements et rapports de transition ; garde-fou Redis edgar:seen_accn
(TTL 7 j) + edgar:failed_accn (3 tentatives) ; companyfacts_404 enregistre quand même
les dépôts et n'est plus compté comme échec (1 671 « failures » venaient de deux CIK
sans facts retentés à chaque cycle) ; failure_samples désormais cumulés (plus écrasés
par un cycle sain) ; index quotidien : jours ouvrables seulement, 403/404 = non publié
(les master.idx de fin de semaine n'existent pas) ; bulk rebuild seulement si des
lignes ont changé ; httpx en WARNING dans le script.

E3 — fund_statements.coverage (1,17 Ko × 1,22 M lignes = 1,9 Go) → dictionnaire
fund_coverage_blob(id, sha1, json) référencé par coverage_id ; lecture hybride
(JSON inline hérité OU référence) dans service._rows et bulk.build ;
_replace_statements ne réécrit que si l'empreinte des lignes a changé ;
scripts/migrate_coverage.py convertit l'existant par lots avec reprise (--vacuum
optionnel) ; auto_vacuum=INCREMENTAL sur une base jeune + incremental_vacuum dans
edgar_reconcile.

Tests : tests/test_fundamentals_ingest.py (co-déclarants, boucle du poller, retries,
échantillons, 403 index, réécriture conditionnelle, dictionnaire, migration).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent 02b779a

13 changed files +1,114 −70

modified hfmarketdata/api/bulk/build.py +4 −2
@@ -65,15 +65,17 @@ def build_year(year: int) -> dict:
65 65 order_by=[t.c.filed_date.desc(), t.c.id.desc()]).label("rn")
66 66 sub = select(t, rn).where(t.c.fiscal_year == int(year)).subquery()
67 67 q = select(sub).where(sub.c.rn == 1).order_by(sub.c.ticker, sub.c.statement, sub.c.fiscal_quarter)
68 + from fundamentals.coverage_store import hydrate
68 69 with session() as s:
69 − df = pd.DataFrame([dict(r._mapping) for r in s.execute(q)])
70 + rows = [dict(r._mapping) for r in s.execute(q)]
71 + df = pd.DataFrame(hydrate(rows)) # coverage comes back inline (dictionary reference resolved)
70 72 if not df.empty:
71 73 df = df.drop(columns=["rn", "id"])
72 74 df["coverage"] = df["coverage"].map(lambda v: json.dumps(v) if v is not None else None)
73 75 for c in ("period_start", "period_end", "filed_date"):
74 76 df[c] = pd.to_datetime(df[c])
75 77 else:
76 − df = pd.DataFrame(columns=[c for c in t.c.keys() if c != "id"])
78 + df = pd.DataFrame(columns=[c for c in t.c.keys() if c not in ("id", "coverage_id")])
77 79 p = fundamentals_path(year)
78 80 tmp = p.with_suffix(".parquet.tmp")
79 81 df.to_parquet(tmp, index=False, compression="zstd")
added hfmarketdata/api/fundamentals/coverage_store.py +128 −0
@@ -0,0 +1,128 @@
1 +"""Dictionary encoding of `fund_statements.coverage`.
2 +
3 +The coverage document of a statement row ({account: {"reason": …} | {"tag": …, "priority": …} | {"computed": …}})
4 +averages 1.1 KB and repeats massively (a company's quarters share the same null reasons): 1.22 M rows carried
5 +1.9 GB of JSON for a few tens of thousands of distinct documents. Rows now store `coverage_id` →
6 +`fund_coverage_blob(id, sha1, json)` and leave `coverage` NULL.
7 +
8 +* `intern(docs)` → ids for a batch of documents (insert-or-get by sha1 of the canonical JSON)
9 +* `hydrate(rows)` → puts the `coverage` dict back on rows read from the table (inline legacy JSON or
10 + dictionary reference — both shapes coexist until `scripts/migrate_coverage.py` is done)
11 +* `digest(doc)` → canonical sha1 (also used by the change detection of `ingest._replace_statements`)
12 +
13 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
14 +"""
15 +from __future__ import annotations
16 +
17 +import hashlib
18 +import json
19 +import threading
20 +from typing import Any
21 +
22 +from sqlalchemy import select
23 +from sqlalchemy.dialects.sqlite import insert as sqlite_insert
24 +
25 +from core.db import session
26 +
27 +from .models import FundCoverageBlob
28 +
29 +_lock = threading.Lock()
30 +_id_by_sha: dict[str, int] = {} # process-wide caches (blobs are immutable)
31 +_doc_by_id: dict[int, dict[str, Any]] = {}
32 +CACHE_MAX = 50_000
33 +
34 +
35 +def canonical(doc: dict[str, Any] | None) -> str:
36 + return json.dumps(doc or {}, sort_keys=True, separators=(",", ":"), default=str)
37 +
38 +
39 +def digest(doc: dict[str, Any] | None) -> str:
40 + return hashlib.sha1(canonical(doc).encode()).hexdigest()
41 +
42 +
43 +def _remember(sha: str, id_: int, doc: dict[str, Any] | None) -> None:
44 + with _lock:
45 + if len(_id_by_sha) > CACHE_MAX:
46 + _id_by_sha.clear()
47 + _doc_by_id.clear()
48 + _id_by_sha[sha] = id_
49 + if doc is not None:
50 + _doc_by_id[id_] = doc
51 +
52 +
53 +def intern(docs: list[dict[str, Any] | None]) -> list[int]:
54 + """Return the blob id of every document (same order), inserting the unseen ones in one round trip."""
55 + shas = [digest(d) for d in docs]
56 + by_sha: dict[str, dict[str, Any] | None] = {}
57 + for s, d in zip(shas, docs):
58 + by_sha.setdefault(s, d)
59 + with _lock:
60 + missing = [s for s in by_sha if s not in _id_by_sha]
61 + if missing:
62 + with session() as s:
63 + for i in range(0, len(missing), 500):
64 + chunk = missing[i:i + 500]
65 + s.execute(sqlite_insert(FundCoverageBlob).on_conflict_do_nothing(index_elements=["sha1"]),
66 + [{"sha1": sha, "json": canonical(by_sha[sha])} for sha in chunk])
67 + for row in s.execute(select(FundCoverageBlob.id, FundCoverageBlob.sha1).where(FundCoverageBlob.sha1.in_(chunk))):
68 + _remember(row.sha1, int(row.id), by_sha[row.sha1] or {})
69 + with _lock:
70 + return [_id_by_sha[sha] for sha in shas]
71 +
72 +
73 +def fetch(ids: set[int]) -> dict[int, dict[str, Any]]:
74 + """Documents of the given blob ids (cache first, then one query for the rest)."""
75 + out: dict[int, dict[str, Any]] = {}
76 + with _lock:
77 + for i in ids:
78 + if i in _doc_by_id:
79 + out[i] = _doc_by_id[i]
80 + todo = [i for i in ids if i not in out]
81 + if todo:
82 + with session() as s:
83 + for i in range(0, len(todo), 500):
84 + for row in s.execute(select(FundCoverageBlob.id, FundCoverageBlob.sha1, FundCoverageBlob.json)
85 + .where(FundCoverageBlob.id.in_(todo[i:i + 500]))):
86 + doc = json.loads(row.json)
87 + out[int(row.id)] = doc
88 + _remember(row.sha1, int(row.id), doc)
89 + return out
90 +
91 +
92 +def sha_of_ids(ids: set[int]) -> dict[int, str]:
93 + """sha1 of the given blob ids (for change detection without loading the documents)."""
94 + out: dict[int, str] = {}
95 + with _lock:
96 + rev = {i: s for s, i in _id_by_sha.items()}
97 + for i in ids:
98 + if i in rev:
99 + out[i] = rev[i]
100 + todo = [i for i in ids if i not in out]
101 + if todo:
102 + with session() as s:
103 + for i in range(0, len(todo), 500):
104 + for row in s.execute(select(FundCoverageBlob.id, FundCoverageBlob.sha1).where(FundCoverageBlob.id.in_(todo[i:i + 500]))):
105 + out[int(row.id)] = row.sha1
106 + _remember(row.sha1, int(row.id), None)
107 + return out
108 +
109 +
110 +def hydrate(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
111 + """In place: rows read from `fund_statements` get their `coverage` dict (from the inline JSON or the
112 + dictionary); `coverage_id` is removed so callers see the historical row shape."""
113 + ids = {int(r["coverage_id"]) for r in rows if r.get("coverage") is None and r.get("coverage_id") is not None}
114 + docs = fetch(ids) if ids else {}
115 + for r in rows:
116 + cid = r.pop("coverage_id", None)
117 + if r.get("coverage") is None:
118 + r["coverage"] = docs.get(int(cid), {}) if cid is not None else {}
119 + return rows
120 +
121 +
122 +def reset_cache_for_tests() -> None:
123 + with _lock:
124 + _id_by_sha.clear()
125 + _doc_by_id.clear()
126 +
127 +
128 +__all__ = ["intern", "fetch", "hydrate", "digest", "canonical", "sha_of_ids", "reset_cache_for_tests"]
modified hfmarketdata/api/fundamentals/edgar_client.py +25 −7
@@ -35,8 +35,15 @@ URL_COMPANY_TICKERS = f"{WWW_BASE}/files/company_tickers.json"
35 35 URL_COMPANY_TICKERS_EXCHANGE = f"{WWW_BASE}/files/company_tickers_exchange.json"
36 36 URL_ATOM_CURRENT = WWW_BASE + "/cgi-bin/browse-edgar?action=getcurrent&type={form}&owner=include&count=100&output=atom"
37 37
38 −TRACKED_FORMS = ("10-K", "10-Q", "8-K", "20-F", "10-K/A", "10-Q/A", "20-F/A", "40-F", "6-K")
39 −FINANCIAL_FORMS = ("10-K", "10-Q", "20-F", "40-F", "10-K/A", "10-Q/A", "20-F/A", "10-KT", "10-QT")
38 +# Forms recorded in edgar_filings and accepted from the live feeds. Amendments (`/A`) and transition reports
39 +# (`10-KT`, `10-QT`) are included so an accession seen in the Atom feed always lands in the table — an
40 +# untracked form seen in the feed (e.g. an 8-K/A returned by the `type=8-K` feed) would otherwise be "new" at
41 +# every polling cycle and re-ingest its company for ever.
42 +TRACKED_FORMS = ("10-K", "10-Q", "8-K", "20-F", "40-F", "6-K", "10-KT", "10-QT",
43 + "10-K/A", "10-Q/A", "8-K/A", "20-F/A", "40-F/A", "6-K/A", "10-KT/A", "10-QT/A")
44 +FINANCIAL_FORMS = ("10-K", "10-Q", "20-F", "40-F", "10-K/A", "10-Q/A", "20-F/A", "40-F/A", "10-KT", "10-QT",
45 + "10-KT/A", "10-QT/A")
46 +MISSING_STATUS = (403, 404) # www.sec.gov/Archives (S3) answers 403 for objects that do not exist yet (weekend indexes)
40 47
41 48
42 49 def cik10(cik: int | str) -> str:
@@ -105,7 +112,9 @@ class EdgarClient:
105 112 "User-Agent": settings.sec_user_agent, "Accept-Encoding": "gzip, deflate", "Accept": "application/json,*/*"})
106 113
107 114 # ------------------------------------------------------------------------------------ low level
108 − def _request(self, url: str, *, accept_404: bool = False) -> httpx.Response | None:
115 + def _request(self, url: str, *, accept_404: bool = False, accept_missing: bool = False) -> httpx.Response | None:
116 + """GET with the token bucket and backoff. `accept_404` → None on 404; `accept_missing` → None on 403/404
117 + (Archives objects that are not published yet, e.g. a daily index on a weekend)."""
109 118 delay = 0.5
110 119 for attempt in range(self.max_retries + 1):
111 120 self.bucket.acquire()
@@ -122,6 +131,8 @@ class EdgarClient:
122 131 continue
123 132 if r.status_code == 404 and accept_404:
124 133 return None
134 + if r.status_code in MISSING_STATUS and accept_missing:
135 + return None
125 136 if r.status_code in (429, 503, 500, 502, 504):
126 137 if attempt == self.max_retries:
127 138 self.stats.errors.append(f"{url}: HTTP {r.status_code}")
@@ -159,9 +170,16 @@ class EdgarClient:
159 170 tmp.replace(cache_path)
160 171 return data
161 172
162 − def get_text(self, url: str) -> str:
163 − r = self._request(url)
164 − return r.text if r is not None else ""
173 + def get_text(self, url: str, *, accept_missing: bool = False) -> str | None:
174 + """Body as text; None when `accept_missing` and the object does not exist (403/404)."""
175 + r = self._request(url, accept_missing=accept_missing)
176 + return r.text if r is not None else None
177 +
178 + def daily_index(self, d: date) -> str | None:
179 + """`master.YYYYMMDD.idx` of one day, None when not published (weekend, holiday, not yet generated).
180 + Indexes live under `Archives/edgar/daily-index/{year}/QTR{q}/`."""
181 + q = (d.month - 1) // 3 + 1
182 + return self.get_text(f"{WWW_BASE}/Archives/edgar/daily-index/{d.year}/QTR{q}/master.{d:%Y%m%d}.idx", accept_missing=True)
165 183
166 184 # ------------------------------------------------------------------------------------ documents
167 185 def company_tickers(self, max_age_hours: float = 24) -> dict[str, Any]:
@@ -228,7 +246,7 @@ class EdgarClient:
228 246
229 247 def atom_current(self, form: str = "") -> str:
230 248 """Fallback feed (Atom) of the latest filings on EDGAR, optionally filtered by form type."""
231 − return self.get_text(URL_ATOM_CURRENT.format(form=form))
249 + return self.get_text(URL_ATOM_CURRENT.format(form=form)) or ""
232 250
233 251 def close(self) -> None:
234 252 self._http.close()
modified hfmarketdata/api/fundamentals/ingest.py +233 −43
@@ -22,11 +22,13 @@ from pathlib import Path
22 22 from typing import Any
23 23
24 24 import pandas as pd
25 −from sqlalchemy import delete, insert, select
25 +from sqlalchemy import delete, insert, select, update
26 +from sqlalchemy.dialects.sqlite import insert as sqlite_insert
26 27
27 28 from core.config import settings
28 29 from core.db import session
29 30
31 +from . import coverage_store as CS
30 32 from . import mapping as M
31 33 from . import utcnow
32 34 from . import normalize as N
@@ -39,7 +41,13 @@ from .service import ratio_inputs
39 41
40 42 log = logging.getLogger("hfmarketdata.fundamentals.ingest")
41 43
42 −EVENT_FORMS = ("10-K", "10-Q", "8-K", "20-F", "10-K/A", "10-Q/A", "20-F/A", "40-F", "6-K")
44 +EVENT_FORMS = TRACKED_FORMS # every tracked filing (one event per co-registrant) is published
45 +NO_FACTS = "companyfacts_404" # permanent: no XBRL facts on EDGAR (funds, trusts, paper filers)
46 +SEEN_KEY = "edgar:seen_accn" # Redis SET of "cik:accn" pairs already handled by the poller
47 +SEEN_TTL_S = 7 * 86_400
48 +FAILED_KEY = "edgar:failed_accn" # Redis HASH "cik:accn" → attempts (transient errors are retried…)
49 +MAX_ATTEMPTS = 3 # …this many times, then the pair is marked seen and left to reconcile
50 +FAILURE_SAMPLES_MAX = 10
43 51
44 52
45 53 def facts_dir(cik: int) -> Path:
@@ -145,6 +153,12 @@ class IngestResult:
145 153 seconds: float = 0.0
146 154 error: str | None = None
147 155 new_filings: list[str] = field(default_factory=list)
156 + changed: bool = False # statements rewritten (content differed from what was stored)
157 +
158 + @property
159 + def failed(self) -> bool:
160 + """A real failure — `companyfacts_404` is a permanent, expected state, not an error to count."""
161 + return self.error is not None and self.error != NO_FACTS
148 162
149 163
150 164 def ingest_company(client: EdgarClient | None, cik: int, *, refresh: bool = False, with_metalinks: bool = True,
@@ -160,7 +174,12 @@ def ingest_company(client: EdgarClient | None, cik: int, *, refresh: bool = Fals
160 174 cf = companyfacts if companyfacts is not None else client.companyfacts(cik, refresh=refresh)
161 175 sub = submissions if submissions is not None else client.submissions(cik, refresh=refresh)
162 176 if cf is None:
163 − res.error = "companyfacts_404"
177 + # no XBRL facts: still record the filings so the accessions are known (the poller must not see them
178 + # as new for ever) and stamp the company
179 + res.error = NO_FACTS
180 + filings = N.filings_from_submissions(sub)
181 + _upsert_filings(cik, filings, pd.DataFrame())
182 + res.filings = len(filings)
164 183 _mark_company(cik, sub, None)
165 184 return res
166 185 facts = N.facts_frame(cf)
@@ -179,7 +198,7 @@ def ingest_company(client: EdgarClient | None, cik: int, *, refresh: bool = Fals
179 198 except Exception as e: # pragma: no cover
180 199 log.warning("metalinks %s/%s: %s", cik, latest_10k.accn, e)
181 200 norm = N.normalize_company(int(cik), ticker, facts, filings, fye, metalinks)
182 − _replace_statements(cik, norm.rows)
201 + res.changed = _replace_statements(cik, norm.rows)
183 202 _upsert_mapping_log(cik, norm.mapping_log)
184 203 cov = compute_coverage(cik, ticker, norm.rows, len(filings), norm.stats)
185 204 _mark_company(cik, sub, fye)
@@ -217,39 +236,120 @@ def _known_accns(cik: int) -> set[str]:
217 236 return set(s.scalars(select(EdgarFiling.accn).where(EdgarFiling.cik == int(cik))))
218 237
219 238
220 −def _upsert_filings(cik: int, filings: dict[str, N.Filing], facts: pd.DataFrame) -> None:
239 +def _known_pairs(pairs: set[tuple[int, str]]) -> set[tuple[int, str]]:
240 + """Subset of the (cik, accn) pairs already in edgar_filings — looked up by accn in chunks, never a full scan."""
241 + if not pairs:
242 + return set()
243 + accns = sorted({a for _, a in pairs})
244 + found: set[tuple[int, str]] = set()
245 + with session() as s:
246 + for i in range(0, len(accns), 500):
247 + for cik, accn in s.execute(select(EdgarFiling.cik, EdgarFiling.accn).where(EdgarFiling.accn.in_(accns[i:i + 500]))):
248 + found.add((int(cik), accn))
249 + return found & pairs
250 +
251 +
252 +def _upsert_filings(cik: int, filings: dict[str, N.Filing], facts: pd.DataFrame) -> int:
253 + """Insert the tracked filings of one company, update the XBRL/period flags of the known ones.
254 +
255 + Inserts go through `INSERT … ON CONFLICT DO NOTHING`: with the composite key (cik, accn) a co-registrant's copy
256 + of a shared accession is a distinct row; on a database not migrated yet (legacy key on accn alone) the copy is
257 + skipped instead of aborting the whole transaction. Returns the number of rows inserted."""
221 258 xbrl_accns = set(facts["accn"].unique()) if not facts.empty else set()
222 259 now = utcnow()
260 + t = EdgarFiling.__table__
261 + inserted = 0
223 262 with session() as s:
224 − existing = {f.accn: f for f in s.scalars(select(EdgarFiling).where(EdgarFiling.cik == int(cik)))}
263 + existing = {r.accn: r for r in s.execute(select(t.c.accn, t.c.is_xbrl, t.c.period_of_report, t.c.parsed_at)
264 + .where(t.c.cik == int(cik)))}
265 + new_rows: list[dict[str, Any]] = []
225 266 for accn, f in filings.items():
226 267 if f.form not in TRACKED_FORMS and accn not in xbrl_accns:
227 268 continue
228 269 row = existing.get(accn)
229 270 if row is None:
230 − s.add(EdgarFiling(accn=accn, cik=int(cik), form=f.form, filed_date=f.filed, period_of_report=f.report_date,
231 − primary_doc=primary_doc_url(int(cik), accn, f.primary_doc), is_amendment=f.is_amendment,
232 − is_xbrl=f.is_xbrl or accn in xbrl_accns, parsed_at=now if accn in xbrl_accns else None))
233 − else:
234 − row.is_xbrl = row.is_xbrl or accn in xbrl_accns
235 − row.period_of_report = row.period_of_report or f.report_date
236 − if accn in xbrl_accns:
237 − row.parsed_at = now
271 + new_rows.append({"cik": int(cik), "accn": accn, "form": f.form, "filed_date": f.filed,
272 + "period_of_report": f.report_date, "primary_doc": primary_doc_url(int(cik), accn, f.primary_doc),
273 + "is_amendment": bool(f.is_amendment), "is_xbrl": bool(f.is_xbrl or accn in xbrl_accns),
274 + "parsed_at": now if accn in xbrl_accns else None})
275 + continue
276 + values: dict[str, Any] = {}
277 + if accn in xbrl_accns:
278 + if not row.is_xbrl:
279 + values["is_xbrl"] = True
280 + values["parsed_at"] = now
281 + if row.period_of_report is None and f.report_date is not None:
282 + values["period_of_report"] = f.report_date
283 + if values:
284 + s.execute(update(t).where(t.c.cik == int(cik), t.c.accn == accn).values(**values))
285 + for i in range(0, len(new_rows), 500):
286 + res = s.execute(sqlite_insert(t).on_conflict_do_nothing(), new_rows[i:i + 500])
287 + inserted += max(int(res.rowcount or 0), 0)
288 + return inserted
289 +
290 +
291 +_STATEMENT_CONTENT_COLUMNS = tuple(c for c in fund_statements.c.keys() if c not in ("id", "coverage", "coverage_id"))
292 +
293 +
294 +def _statement_fingerprint(rec: dict[str, Any], coverage_sha: str) -> str:
295 + """Canonical string of one statement row (dates/bools/floats normalised) + the sha1 of its coverage."""
296 + parts = []
297 + for c in _STATEMENT_CONTENT_COLUMNS:
298 + v = rec.get(c)
299 + if isinstance(v, (datetime, date)):
300 + v = N._d(v).isoformat() if N._d(v) else None
301 + elif isinstance(v, bool):
302 + v = int(v)
303 + elif isinstance(v, float):
304 + v = None if (math.isnan(v) or math.isinf(v)) else repr(float(v))
305 + elif isinstance(v, int) and not isinstance(v, bool):
306 + v = repr(float(v)) if c not in ("cik", "fiscal_year", "fiscal_quarter") else v
307 + parts.append(f"{c}={v}")
308 + return "|".join(parts) + f"|coverage={coverage_sha}"
309 +
310 +
311 +def _stored_fingerprints(cik: int) -> set[str]:
312 + with session() as s:
313 + stored = [dict(r._mapping) for r in s.execute(select(fund_statements).where(fund_statements.c.cik == int(cik)))]
314 + ids = {int(r["coverage_id"]) for r in stored if r.get("coverage") is None and r.get("coverage_id") is not None}
315 + shas = CS.sha_of_ids(ids) if ids else {}
316 + out = set()
317 + for r in stored:
318 + if r.get("coverage") is not None:
319 + sha = CS.digest(r["coverage"])
320 + else:
321 + sha = shas.get(int(r["coverage_id"]), CS.digest({})) if r.get("coverage_id") is not None else CS.digest({})
322 + out.add(_statement_fingerprint(r, sha))
323 + return out
238 324
239 325
240 −def _replace_statements(cik: int, rows: list[dict[str, Any]]) -> None:
326 +def _replace_statements(cik: int, rows: list[dict[str, Any]]) -> bool:
327 + """Store the standardized rows of one company; coverage documents go through the dictionary.
328 +
329 + The table is rewritten (DELETE + INSERT) only when the content differs from what is stored — a poll cycle
330 + that re-normalises an unchanged company then costs one SELECT instead of ~350 deletes/inserts (and no
331 + fragmentation). Returns True when rows were written."""
241 332 cols = [c for c in fund_statements.c.keys() if c != "id"]
242 − clean = []
333 + clean: list[dict[str, Any]] = []
243 334 for r in rows:
244 335 rec = {k: r.get(k) for k in cols} # executemany needs identical keys on every row
245 336 for k, v in rec.items():
246 337 if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
247 338 rec[k] = None
248 339 clean.append(rec)
340 + shas = [CS.digest(rec.get("coverage")) for rec in clean]
341 + fresh = {_statement_fingerprint(rec, sha) for rec, sha in zip(clean, shas)}
342 + if fresh == _stored_fingerprints(cik):
343 + return False
344 + ids = CS.intern([rec.get("coverage") for rec in clean]) if clean else []
345 + for rec, cid in zip(clean, ids):
346 + rec["coverage_id"] = cid
347 + rec["coverage"] = None
249 348 with session() as s:
250 349 s.execute(delete(fund_statements).where(fund_statements.c.cik == int(cik)))
251 350 for i in range(0, len(clean), 500):
252 351 s.execute(insert(fund_statements), clean[i:i + 500])
352 + return True
253 353
254 354
255 355 def _upsert_mapping_log(cik: int, entries: list[dict[str, Any]]) -> None:
@@ -428,43 +528,132 @@ def publish_filing_event(cik: int, ticker: str, form: str, filed: date, period:
428 528
429 529
430 530 # ------------------------------------------------------------------------------------ incremental poll
531 +def _redis():
532 + """Sync Redis client shared with the stream broker (fakeredis under HFMD_REDIS_URL=fakeredis://); None when down."""
533 + try:
534 + from stream.broker import get_redis
535 + r = get_redis()
536 + r.ping()
537 + return r
538 + except Exception as e:
539 + log.warning("redis unavailable for the poller guard: %s", e)
540 + return None
541 +
542 +
543 +def _pair_key(cik: int, accn: str) -> str:
544 + return f"{int(cik)}:{accn}"
545 +
546 +
547 +def _redis_seen(r, pairs: set[tuple[int, str]]) -> set[tuple[int, str]]:
548 + if r is None or not pairs:
549 + return set()
550 + items = sorted(pairs)
551 + flags = r.smismember(SEEN_KEY, [_pair_key(c, a) for c, a in items])
552 + return {p for p, f in zip(items, flags) if f}
553 +
554 +
555 +def _redis_mark_seen(r, pairs: set[tuple[int, str]]) -> None:
556 + if r is None or not pairs:
557 + return
558 + r.sadd(SEEN_KEY, *[_pair_key(c, a) for c, a in pairs])
559 + r.expire(SEEN_KEY, SEEN_TTL_S)
560 + r.hdel(FAILED_KEY, *[_pair_key(c, a) for c, a in pairs])
561 +
562 +
563 +def _redis_mark_failed(r, pairs: set[tuple[int, str]]) -> set[tuple[int, str]]:
564 + """Count one failed attempt per pair; returns the pairs that reached MAX_ATTEMPTS (to be marked seen)."""
565 + if r is None or not pairs:
566 + return set()
567 + exhausted = set()
568 + for c, a in pairs:
569 + n = int(r.hincrby(FAILED_KEY, _pair_key(c, a), 1))
570 + if n >= MAX_ATTEMPTS:
571 + exhausted.add((c, a))
572 + r.expire(FAILED_KEY, SEEN_TTL_S)
573 + return exhausted
574 +
575 +
576 +def business_days_back(today: date, n: int = 2) -> list[date]:
577 + """The last `n` weekdays ending today (today included when it is a weekday). EDGAR publishes no index on
578 + Saturdays/Sundays — asking for them only produces 403s."""
579 + out: list[date] = []
580 + d = today
581 + while len(out) < n:
582 + if d.weekday() < 5:
583 + out.append(d)
584 + d -= timedelta(days=1)
585 + return out
586 +
587 +
431 588 def poll_new_filings(client: EdgarClient, *, since: datetime | None = None, forms: tuple[str, ...] = ("10-K", "10-Q", "8-K", "20-F"),
432 589 include_daily_index: bool = True) -> dict[str, Any]:
433 − """One polling cycle: Atom `getcurrent` per form (+ yesterday/today master index as a safety net) →
434 − filter tracked CIKs → re-ingest them (fresh companyfacts) → publish events. Returns a summary."""
590 + """One polling cycle: Atom `getcurrent` per form (+ the last two business-day master indexes as a safety
591 + net) → tracked (cik, accn) pairs of tracked forms → minus what edgar_filings and the Redis guard already know
592 + → re-ingest the affected companies (fresh companyfacts) → publish events. Returns a summary.
593 +
594 + Termination guards (the poller re-ingested one company every cycle for 41 h before them):
595 + * only `TRACKED_FORMS` are considered, so every accepted accession ends up in edgar_filings;
596 + * pairs are keyed (cik, accn): co-registrants of one accession are all ingested, once each;
597 + * Redis SET `edgar:seen_accn` (7 days) remembers every pair handled — even when the ingestion could not
598 + record it (transient EDGAR error: retried `MAX_ATTEMPTS` times, then left to the reconcile job)."""
435 599 tracked = set(tracked_ciks())
436 − seen: dict[str, dict[str, Any]] = {}
600 + seen: dict[tuple[int, str], dict[str, Any]] = {}
437 601 for form in forms:
438 602 try:
439 603 for e in parse_atom(client.atom_current(form)):
440 − if e["cik"] in tracked and e["accn"]:
441 − seen[e["accn"]] = e
604 + if e["cik"] in tracked and e["accn"] and (e.get("form") or form) in TRACKED_FORMS:
605 + seen.setdefault((int(e["cik"]), e["accn"]), e)
442 606 except Exception as ex: # pragma: no cover
443 607 log.warning("atom %s: %s", form, ex)
608 + index_days: list[str] = []
444 609 if include_daily_index:
445 − for back in (0, 1):
446 − d = date.today() - timedelta(days=back)
610 + for d in business_days_back(date.today(), 2):
447 611 try:
448 − for e in parse_master_index(client.get_text(master_index_url(d))):
449 − if e["cik"] in tracked and e["form"] in TRACKED_FORMS and e["accn"] not in seen:
450 − seen[e["accn"]] = e
451 − except Exception:
612 + text = client.daily_index(d)
613 + except Exception as ex:
614 + log.warning("daily index %s: %s", d, ex)
452 615 continue
453 − known = _all_known_accns()
454 − new = {a: e for a, e in seen.items() if a not in known}
455 − affected = sorted({e["cik"] for e in new.values()})
456 − results = []
616 + if text is None:
617 + log.debug("daily index %s not published yet", d)
618 + continue
619 + index_days.append(d.isoformat())
620 + for e in parse_master_index(text):
621 + if e["cik"] in tracked and e["form"] in TRACKED_FORMS:
622 + seen.setdefault((int(e["cik"]), e["accn"]), e)
623 + r = _redis()
624 + pairs = set(seen)
625 + known = _known_pairs(pairs)
626 + guarded = _redis_seen(r, pairs - known)
627 + new = {p: e for p, e in seen.items() if p not in known and p not in guarded}
628 + affected = sorted({c for c, _ in new})
629 + results: list[IngestResult] = []
630 + handled: set[tuple[int, str]] = set()
631 + failed_pairs: set[tuple[int, str]] = set()
457 632 for cik in affected:
458 − results.append(ingest_company(client, cik, refresh=True, with_metalinks=False, publish=True))
633 + res = ingest_company(client, cik, refresh=True, with_metalinks=False, publish=True)
634 + results.append(res)
635 + mine = {p for p in new if p[0] == cik}
636 + if res.failed:
637 + failed_pairs |= mine
638 + else:
639 + handled |= mine
640 + exhausted = _redis_mark_failed(r, failed_pairs)
641 + if exhausted:
642 + log.warning("giving up on %d accession(s) after %d attempts: %s", len(exhausted), MAX_ATTEMPTS, sorted(exhausted)[:5])
643 + _redis_mark_seen(r, handled | exhausted)
459 644 now = utcnow()
460 645 newest = max((datetime.fromisoformat(e["filed"]) for e in seen.values() if e.get("filed")), default=None)
461 646 lag = (now - newest).total_seconds() if newest else None
647 + failures = [r_.error for r_ in results if r_.failed]
462 648 _set_state("incremental", last_run_at=now, last_success_at=now, last_rss_check_at=now, lag_seconds=lag,
463 649 requests_made=client.stats.requests, last_filing_seen=newest,
464 − failures_add=sum(1 for r in results if r.error), failure_samples=[r.error for r in results if r.error][:5])
465 − _update_mapping_failure_rate()
466 − return {"seen": len(seen), "new": len(new), "affected_ciks": affected,
467 − "events": sum(len(r.new_filings) for r in results), "errors": [r.error for r in results if r.error]}
650 + failures_add=len(failures), failure_samples_add=[f"cik={r_.cik} {r_.error}" for r_ in results if r_.failed])
651 + if any(r_.changed for r_ in results):
652 + _update_mapping_failure_rate()
653 + return {"seen": len(seen), "new": len(new), "affected_ciks": affected, "index_days": index_days,
654 + "events": sum(len(r_.new_filings) for r_ in results), "errors": [r_.error for r_ in results if r_.error],
655 + "failures": failures, "rows_changed": sum(1 for r_ in results if r_.changed),
656 + "no_facts": [r_.cik for r_ in results if r_.error == NO_FACTS]}
468 657
469 658
470 659 def master_index_url(d: date) -> str:
@@ -489,13 +678,10 @@ def parse_master_index(text: str) -> list[dict[str, Any]]:
489 678 return out
490 679
491 680
492 −def _all_known_accns() -> set[str]:
493 − with session() as s:
494 − return set(s.scalars(select(EdgarFiling.accn)))
495 −
496 −
497 681 # ------------------------------------------------------------------------------------------- state
498 −def _set_state(key: str, *, failures_add: int = 0, **fields: Any) -> None:
682 +def _set_state(key: str, *, failures_add: int = 0, failure_samples_add: list[str] | None = None, **fields: Any) -> None:
683 + """Update one job row. `failure_samples_add` appends (most recent last, capped at FAILURE_SAMPLES_MAX) so a
684 + later successful cycle never wipes the evidence of earlier failures; `failure_samples=` still replaces."""
499 685 with session() as s:
500 686 st = s.get(FundIngestState, key)
501 687 if st is None:
@@ -504,6 +690,9 @@ def _set_state(key: str, *, failures_add: int = 0, **fields: Any) -> None:
504 690 for k, v in fields.items():
505 691 setattr(st, k, v)
506 692 st.failures = (st.failures or 0) + failures_add
693 + if failure_samples_add:
694 + samples = list(st.failure_samples or []) + [str(x)[:2000] for x in failure_samples_add]
695 + st.failure_samples = samples[-FAILURE_SAMPLES_MAX:]
507 696
508 697
509 698 def _bump_state(key: str, **counters: int) -> None:
@@ -537,4 +726,5 @@ def state_snapshot() -> dict[str, Any]:
537 726
538 727
539 728 __all__ = ["sync_universe", "ingest_company", "refresh_latest", "build_latest_all", "poll_new_filings",
540 − "publish_filing_event", "compute_coverage", "tracked_ciks", "parse_master_index", "master_index_url"]
729 + "publish_filing_event", "compute_coverage", "tracked_ciks", "parse_master_index", "master_index_url",
730 + "business_days_back", "IngestResult", "NO_FACTS", "SEEN_KEY", "FAILED_KEY"]
added hfmarketdata/api/fundamentals/migrations.py +151 −0
@@ -0,0 +1,151 @@
1 +"""Idempotent schema maintenance for the fundamentals tables (SQLite cannot ALTER a primary key).
2 +
3 +Called by `models.init_db()` at module start and by the scripts; every step is a no-op once applied:
4 +
5 +* `edgar_filings` key `accn` → `(cik, accn)` (co-registrants share accession numbers). The table is rebuilt
6 + (`edgar_filings_new` ← `INSERT OR IGNORE`, drop, rename, indexes). Small tables (≤ `INLINE_MAX_ROWS`) are
7 + migrated inline at startup; bigger ones (production: 1.37 M rows, ≈ 30–60 s) are left alone with a warning
8 + and `scripts/migrate_edgar_filings.py` must be run before the deployment — the ingestion code tolerates
9 + the old shape meanwhile (`INSERT … ON CONFLICT DO NOTHING`).
10 +* `fund_statements.coverage_id` column (dictionary reference, see `coverage_store.py`).
11 +* `PRAGMA auto_vacuum=INCREMENTAL` when the database is brand new (must be set before the first table).
12 +
13 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
14 +"""
15 +from __future__ import annotations
16 +
17 +import logging
18 +import time
19 +
20 +from sqlalchemy import text
21 +from sqlalchemy.engine import Connection
22 +
23 +from core.db import engine
24 +
25 +log = logging.getLogger("hfmarketdata.fundamentals.migrations")
26 +
27 +INLINE_MAX_ROWS = 300_000
28 +
29 +_FILINGS_COLUMNS = ("cik", "accn", "form", "filed_date", "period_of_report", "primary_doc", "is_amendment", "is_xbrl", "parsed_at")
30 +_FILINGS_DDL = """
31 +CREATE TABLE {name} (
32 + cik INTEGER NOT NULL,
33 + accn VARCHAR(24) NOT NULL,
34 + form VARCHAR(16) NOT NULL,
35 + filed_date DATE NOT NULL,
36 + period_of_report DATE,
37 + primary_doc VARCHAR(512),
38 + is_amendment BOOLEAN NOT NULL,
39 + is_xbrl BOOLEAN NOT NULL,
40 + parsed_at DATETIME,
41 + PRIMARY KEY (cik, accn)
42 +)"""
43 +_FILINGS_INDEXES = (
44 + "CREATE INDEX IF NOT EXISTS ix_edgar_filings_cik_filed ON edgar_filings (cik, filed_date)",
45 + "CREATE INDEX IF NOT EXISTS ix_edgar_filings_accn ON edgar_filings (accn)",
46 + "CREATE INDEX IF NOT EXISTS ix_edgar_filings_form ON edgar_filings (form)",
47 + "CREATE INDEX IF NOT EXISTS ix_edgar_filings_filed_date ON edgar_filings (filed_date)",
48 +)
49 +
50 +
51 +def _table_exists(con: Connection, name: str) -> bool:
52 + return con.execute(text("SELECT 1 FROM sqlite_master WHERE type='table' AND name=:n"), {"n": name}).first() is not None
53 +
54 +
55 +def _columns(con: Connection, table: str) -> list[dict]:
56 + return [dict(r._mapping) for r in con.execute(text(f"PRAGMA table_info({table})"))]
57 +
58 +
59 +def filings_pk_is_composite(con: Connection) -> bool | None:
60 + """True when edgar_filings is keyed on (cik, accn); False for the legacy `accn` key; None when absent."""
61 + if not _table_exists(con, "edgar_filings"):
62 + return None
63 + pk = sorted((c["pk"], c["name"]) for c in _columns(con, "edgar_filings") if c["pk"])
64 + return [n for _, n in pk] == ["cik", "accn"]
65 +
66 +
67 +def filings_row_count(con: Connection) -> int:
68 + return int(con.execute(text("SELECT count(*) FROM edgar_filings")).scalar() or 0)
69 +
70 +
71 +def migrate_filings_pk(con: Connection, *, log_progress: bool = True) -> int:
72 + """Rebuild edgar_filings with the composite key inside one transaction. Returns the rows copied.
73 +
74 + `INSERT OR IGNORE` makes the copy safe to redo (the table is fully reconstructible by ingestion anyway)."""
75 + t0 = time.time()
76 + con.execute(text("DROP TABLE IF EXISTS edgar_filings_new"))
77 + con.execute(text(_FILINGS_DDL.format(name="edgar_filings_new")))
78 + cols = ", ".join(_FILINGS_COLUMNS)
79 + con.execute(text(f"INSERT OR IGNORE INTO edgar_filings_new ({cols}) SELECT {cols} FROM edgar_filings"))
80 + n = int(con.execute(text("SELECT count(*) FROM edgar_filings_new")).scalar() or 0)
81 + con.execute(text("DROP TABLE edgar_filings"))
82 + con.execute(text("ALTER TABLE edgar_filings_new RENAME TO edgar_filings"))
83 + for ddl in _FILINGS_INDEXES:
84 + con.execute(text(ddl))
85 + if log_progress:
86 + log.info("edgar_filings rebuilt with key (cik, accn): %d rows in %.1fs", n, time.time() - t0)
87 + return n
88 +
89 +
90 +def ensure_filings_schema(*, inline_max_rows: int = INLINE_MAX_ROWS) -> str:
91 + """Startup hook: migrate small tables inline, warn for big ones. Returns 'ok' | 'migrated' | 'pending' | 'absent'."""
92 + with engine.begin() as con:
93 + state = filings_pk_is_composite(con)
94 + if state is None:
95 + return "absent"
96 + if state:
97 + return "ok"
98 + n = filings_row_count(con)
99 + if n > inline_max_rows:
100 + log.warning("edgar_filings still keyed on accn alone (%d rows): run scripts/migrate_edgar_filings.py before "
101 + "deploying — co-registrant filings are skipped until then", n)
102 + return "pending"
103 + migrate_filings_pk(con)
104 + return "migrated"
105 +
106 +
107 +def ensure_statements_schema() -> bool:
108 + """Add `fund_statements.coverage_id` when missing (legacy databases). Returns True when added."""
109 + with engine.begin() as con:
110 + if not _table_exists(con, "fund_statements"):
111 + return False
112 + names = {c["name"] for c in _columns(con, "fund_statements")}
113 + if "coverage_id" in names:
114 + return False
115 + con.execute(text("ALTER TABLE fund_statements ADD COLUMN coverage_id INTEGER"))
116 + log.info("fund_statements: column coverage_id added")
117 + return True
118 +
119 +
120 +AUTO_VACUUM_MAX_PAGES = 12_800 # ≈ 50 MB at 4 KB/page: switching mode needs a VACUUM, only cheap on a young DB
121 +
122 +
123 +def ensure_auto_vacuum(*, max_pages: int = AUTO_VACUUM_MAX_PAGES) -> bool:
124 + """Switch a young database (≤ `max_pages`) to `auto_vacuum=INCREMENTAL` (the mode only takes effect through a
125 + VACUUM, instantaneous on a small file). Big existing files are left alone: a one-off offline `VACUUM`
126 + (`scripts/migrate_coverage.py --vacuum`) is the operator's call. Returns True when the mode was switched."""
127 + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as con:
128 + mode = int(con.execute(text("PRAGMA auto_vacuum")).scalar() or 0)
129 + if mode == 2:
130 + return False
131 + pages = int(con.execute(text("PRAGMA page_count")).scalar() or 0)
132 + if pages > max_pages:
133 + return False
134 + con.execute(text("PRAGMA auto_vacuum=INCREMENTAL"))
135 + con.execute(text("VACUUM"))
136 + log.info("sqlite auto_vacuum=INCREMENTAL enabled (%d pages)", pages)
137 + return True
138 +
139 +
140 +def incremental_vacuum(pages: int | None = None) -> int:
141 + """Release free pages when auto_vacuum=INCREMENTAL (no-op otherwise). Returns the freelist count after."""
142 + with engine.connect() as con:
143 + mode = int(con.execute(text("PRAGMA auto_vacuum")).scalar() or 0)
144 + if mode == 2:
145 + con.execute(text(f"PRAGMA incremental_vacuum{f'({int(pages)})' if pages else ''}"))
146 + con.commit()
147 + return int(con.execute(text("PRAGMA freelist_count")).scalar() or 0)
148 +
149 +
150 +__all__ = ["ensure_filings_schema", "ensure_statements_schema", "ensure_auto_vacuum", "migrate_filings_pk",
151 + "filings_pk_is_composite", "filings_row_count", "incremental_vacuum", "INLINE_MAX_ROWS"]
modified hfmarketdata/api/fundamentals/models.py +34 −7
@@ -4,9 +4,10 @@ Raw XBRL facts are NOT here: they live in the Parquet lake `data_root/edgar/fact
4 4 (queried with DuckDB). SQLite holds what needs indexes and point-in-time lookups:
5 5
6 6 * `edgar_companies` CIK ↔ ticker(s) (share classes, history), SIC, exchange, fiscal year end, status
7 −* `edgar_filings` one row per filing (accession number), amendments flagged
7 +* `edgar_filings` one row per (cik, accession number) — co-registrants share accessions —, amendments flagged
8 8 * `fund_statements` wide standardized statements, **versioned by filed_date** (point-in-time index on
9 − (ticker, period_end, filed_date)); `coverage` JSON explains every null
9 + (ticker, period_end, filed_date)); `coverage_id` → `fund_coverage_blob` explains every null
10 +* `fund_coverage_blob` dictionary of distinct coverage JSON documents (sha1-keyed)
10 11 * `fund_mapping` the prioritized tag mapping, versioned (seeded from mapping.py)
11 12 * `fund_mapping_log` unmapped / custom-extension tags seen per CIK (never guessed)
12 13 * `fund_coverage` per ticker: periods, completeness %, gaps
@@ -17,6 +18,7 @@ Author: Simon-Pierre Boucher <contact@spboucher.ai>
17 18 """
18 19 from __future__ import annotations
19 20
21 +import logging
20 22 from datetime import date, datetime
21 23
22 24 from sqlalchemy import (JSON, Boolean, Column, Date, DateTime, Float, Index, Integer, String, Table, Text,
@@ -49,9 +51,14 @@ class EdgarCompany(Base):
49 51
50 52
51 53 class EdgarFiling(Base):
54 + """One row per (co-)registrant and accession number.
55 +
56 + An accession number is NOT unique across companies: a parent and its subsidiaries file one combined 10-Q/8-K
57 + (Entergy 65984 + 66901/65770/…, Southern 92122…) that appears in the submissions of every co-registrant with the
58 + same `accn`. The key is therefore the pair (cik, accn); `accn` alone is indexed."""
52 59 __tablename__ = "edgar_filings"
60 + cik: Mapped[int] = mapped_column(Integer, primary_key=True)
53 61 accn: Mapped[str] = mapped_column(String(24), primary_key=True) # 0000320193-24-000069
54 − cik: Mapped[int] = mapped_column(Integer, index=True)
55 62 form: Mapped[str] = mapped_column(String(16), index=True) # 10-K, 10-Q, 8-K, 20-F, 10-K/A…
56 63 filed_date: Mapped[date] = mapped_column(Date, index=True)
57 64 period_of_report: Mapped[date | None] = mapped_column(Date, nullable=True)
@@ -59,7 +66,18 @@ class EdgarFiling(Base):
59 66 is_amendment: Mapped[bool] = mapped_column(Boolean, default=False)
60 67 is_xbrl: Mapped[bool] = mapped_column(Boolean, default=False)
61 68 parsed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
62 − __table_args__ = (Index("ix_edgar_filings_cik_filed", "cik", "filed_date"),)
69 + __table_args__ = (Index("ix_edgar_filings_cik_filed", "cik", "filed_date"),
70 + Index("ix_edgar_filings_accn", "accn"))
71 +
72 +
73 +class FundCoverageBlob(Base):
74 + """Dictionary of distinct `coverage` JSON documents (the null reasons repeat a lot: ~1.2 KB × 1.2 M rows
75 + collapsed to a few tens of thousands of distinct blobs). `fund_statements.coverage_id` references it; a
76 + row keeps `coverage` inline (legacy) until `scripts/migrate_coverage.py` converts it."""
77 + __tablename__ = "fund_coverage_blob"
78 + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
79 + sha1: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
80 + json: Mapped[str] = mapped_column(Text, nullable=False)
63 81
64 82
65 83 class FundMapping(Base):
@@ -135,7 +153,7 @@ class FundIngestState(Base):
135 153 # ------------------------------------------------------------------------------------------- wide tables
136 154 STATEMENT_KEY_COLUMNS = ("cik", "ticker", "statement", "fiscal_year", "fiscal_quarter", "period_start", "period_end",
137 155 "calendar_quarter", "form", "accn", "filed_date", "derived", "restated", "currency", "coverage",
138 − "mapping_version")
156 + "mapping_version", "coverage_id")
139 157
140 158 fund_statements = Table(
141 159 "fund_statements", Base.metadata,
@@ -154,8 +172,9 @@ fund_statements = Table(
154 172 Column("derived", Boolean, default=False), # Q4 / de-cumulated quarter
155 173 Column("restated", Boolean, default=False), # differs from an earlier version of the same period
156 174 Column("currency", String(3), default="USD"),
157 − Column("coverage", JSON, default=dict), # {account: {"reason": …, "tag": …, "computed": …}}
175 + Column("coverage", JSON, nullable=True), # legacy inline {account: {"reason": …, …}} — NULL once interned
158 176 Column("mapping_version", String(16)),
177 + Column("coverage_id", Integer, nullable=True), # → fund_coverage_blob.id (see coverage_store.py)
159 178 *[Column(name, Float, nullable=True) for name in ALL_ACCOUNT_NAMES],
160 179 UniqueConstraint("cik", "statement", "fiscal_year", "fiscal_quarter", "accn", name="uq_fund_statements_version"),
161 180 Index("ix_fund_statements_pit", "ticker", "period_end", "filed_date"),
@@ -192,8 +211,16 @@ SCREENER_TEXT_FIELDS: tuple[str, ...] = ("ticker", "sic", "exchange", "name")
192 211
193 212
194 213 def init_db() -> None:
195 − """Idempotent create_all + mapping seed (called at module import by routes.py and by the scripts)."""
214 + """Idempotent create_all + schema maintenance + mapping seed (called at module import by routes.py and by
215 + the scripts). Heavy migrations are never run here: see `migrations.ensure_filings_schema`."""
216 + from . import migrations
217 + try:
218 + migrations.ensure_auto_vacuum()
219 + except Exception as e: # pragma: no cover — never block the API on a housekeeping pragma
220 + logging.getLogger("hfmarketdata.fundamentals").warning("auto_vacuum: %s", e)
196 221 create_all()
222 + migrations.ensure_filings_schema()
223 + migrations.ensure_statements_schema()
197 224 from .mapping import MAPPING_VERSION, mapping_rows
198 225 from core.db import session
199 226 from sqlalchemy import select
modified hfmarketdata/api/fundamentals/service.py +4 −1
@@ -74,11 +74,14 @@ def resolve_company(ticker: str) -> EdgarCompany:
74 74
75 75
76 76 def _rows(cik: int, statements: list[str] | None = None) -> list[dict[str, Any]]:
77 + """Every stored version of the company's statements, `coverage` resolved (inline legacy JSON or dictionary)."""
77 78 q = select(fund_statements).where(fund_statements.c.cik == cik)
78 79 if statements:
79 80 q = q.where(fund_statements.c.statement.in_(statements))
80 81 with session() as s:
81 − return [dict(r._mapping) for r in s.execute(q)]
82 + rows = [dict(r._mapping) for r in s.execute(q)]
83 + from . import coverage_store
84 + return coverage_store.hydrate(rows)
82 85
83 86
84 87 def company_rows(cik: int, as_of: date | None, statements: list[str] | None = None,
modified scripts/edgar_backfill.py +1 −1
@@ -119,7 +119,7 @@ def main() -> int:
119 119 except Exception as e: # pragma: no cover
120 120 r = None
121 121 log.exception("cik %s crashed: %s", cik, e)
122 − if r is not None and r.error == "companyfacts_404":
122 + if r is not None and r.error == ingest.NO_FACTS:
123 123 # no XBRL facts on EDGAR (funds, trusts, paper filers): permanent — not retried on resume
124 124 done[str(cik)] = {"status": "no_facts", "ticker": r.ticker, "at": utcnow().isoformat()}
125 125 elif r is None or r.error:
modified scripts/edgar_incremental.py +16 −9
@@ -7,11 +7,14 @@
7 7 venv/bin/python scripts/edgar_incremental.py --once
8 8
9 9 Sources (see fundamentals.ingest.poll_new_filings): the EDGAR Atom feed `browse-edgar?action=getcurrent` for
10 −10-K / 10-Q / 8-K / 20-F (live, ~4 requests per cycle) + the daily master index of today/yesterday as a safety
11 −net. For every new accession of a tracked CIK: companyfacts + submissions are refetched (cache bypass),
12 −statements re-normalised (new versions, restatements), coverage + screener row refreshed, and a `filing`
13 −event is published on Redis (`filings` channel + `filings:stream` buffer) for the WebSocket. Lag, failures and
14 −the mapping failure rate are written to `fund_ingest_state` (GET /v1/fundamentals/_health).
10 +10-K / 10-Q / 8-K / 20-F (live, ~4 requests per cycle) + the daily master index of the last two business days
11 +as a safety net (weekend indexes do not exist: 403 from the SEC, skipped silently). For every new
12 +(cik, accession) pair of a tracked form: companyfacts + submissions are refetched (cache bypass), statements
13 +re-normalised (new versions, restatements) and rewritten only when they changed, coverage + screener row
14 +refreshed, and a `filing` event is published on Redis (`filings` channel + `filings:stream` buffer) for the
15 +WebSocket. A Redis SET `edgar:seen_accn` (7 days) guarantees a pair is handled once even when EDGAR misbehaves.
16 +Lag, failures (+ samples) and the mapping failure rate are written to `fund_ingest_state`
17 +(GET /v1/fundamentals/_health).
15 18
16 19 Author: Simon-Pierre Boucher <contact@spboucher.ai>
17 20 """
@@ -46,6 +49,8 @@ def main() -> int:
46 49 ap.add_argument("-v", "--verbose", action="store_true")
47 50 args = ap.parse_args()
48 51 logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
52 + if not args.verbose:
53 + logging.getLogger("httpx").setLevel(logging.WARNING) # one line per request otherwise (incl. expected 403/404)
49 54 signal.signal(signal.SIGTERM, _sig)
50 55 signal.signal(signal.SIGINT, _sig)
51 56
@@ -60,9 +65,10 @@ def main() -> int:
60 65 t0 = time.time()
61 66 try:
62 67 summary = ingest.poll_new_filings(client, forms=forms, include_daily_index=not args.no_daily_index)
63 − log.info("cycle: seen=%s new=%s affected=%s events=%s errors=%s (%.1fs)", summary["seen"], summary["new"],
64 − summary["affected_ciks"], summary["events"], summary["errors"], time.time() - t0)
65 − if summary["affected_ciks"]:
68 + log.info("cycle: seen=%s new=%s affected=%s changed=%s events=%s errors=%s index=%s (%.1fs)", summary["seen"],
69 + summary["new"], summary["affected_ciks"], summary["rows_changed"], summary["events"], summary["errors"],
70 + summary["index_days"], time.time() - t0)
71 + if summary["rows_changed"]: # nothing new stored → the extracts are still fresh
66 72 try:
67 73 from bulk.build import available_years, build_year
68 74 for y in available_years()[-2:]: # keep the two most recent yearly extracts fresh
@@ -71,7 +77,8 @@ def main() -> int:
71 77 log.warning("bulk rebuild failed: %s", e)
72 78 except Exception as e:
73 79 log.exception("cycle failed: %s", e)
74 − ingest._set_state("incremental", last_run_at=utcnow(), failures_add=1, failure_samples=[str(e)])
80 + ingest._set_state("incremental", last_run_at=utcnow(), failures_add=1,
81 + failure_samples_add=[f"cycle: {type(e).__name__}: {e}"])
75 82 if args.once:
76 83 break
77 84 for _ in range(int(max(1.0, args.interval - (time.time() - t0)))):
modified scripts/edgar_reconcile.py +5 −0
@@ -123,6 +123,11 @@ def main() -> int:
123 123 "discrepancies": sum(len(c["discrepancies"]) for c in report["companies"])})
124 124 if args.json:
125 125 Path(args.json).write_text(json.dumps(report, default=str, indent=1))
126 + try: # housekeeping: hand free pages back to the OS when the database runs auto_vacuum=INCREMENTAL (no-op otherwise)
127 + from fundamentals.migrations import incremental_vacuum
128 + log.info("sqlite incremental_vacuum done, freelist pages left: %d", incremental_vacuum())
129 + except Exception as e: # pragma: no cover
130 + log.warning("incremental_vacuum failed: %s", e)
126 131 log.info("reconcile done: %d/%d companies with discrepancies (%s)", bad, len(sample), client.stats)
127 132 return 1 if bad else 0
128 133
added scripts/migrate_coverage.py +108 −0
@@ -0,0 +1,108 @@
1 +#!/usr/bin/env python3
2 +"""Convert the inline `fund_statements.coverage` JSON of existing rows to `fund_coverage_blob` references.
3 +
4 + cd ~/hfmarketdata && HFMD_DATA_ROOT=~/firstratedata venv/bin/python scripts/migrate_coverage.py # all rows
5 + venv/bin/python scripts/migrate_coverage.py --batch 5000 --max-batches 50 # a slice
6 + venv/bin/python scripts/migrate_coverage.py --vacuum # reclaim space (offline!)
7 +
8 +Runs while the API serves (WAL, short transactions of `--batch` rows; each batch commits, so it can be stopped
9 +and resumed at any time: the work set is simply `WHERE coverage IS NOT NULL`). New rows are written through the
10 +dictionary already (`ingest._replace_statements`), the read side understands both shapes, so this only shrinks the
11 +past: 1.22 M rows × ~1.1 KB ≈ 1.3 GB of JSON → a few tens of thousands of blobs.
12 +
13 +Space is NOT returned to the OS by the conversion itself (free pages are reused by later inserts). `--vacuum`
14 +runs `PRAGMA auto_vacuum=INCREMENTAL` + `VACUUM` afterwards: needs ~2× the file size of free disk, exclusive access
15 +(stop the API and the jobs) and a few minutes for 3 GB — run it once, off-hours.
16 +
17 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
18 +"""
19 +from __future__ import annotations
20 +
21 +import argparse
22 +import json
23 +import logging
24 +import sys
25 +import time
26 +from pathlib import Path
27 +
28 +HERE = Path(__file__).resolve().parent
29 +sys.path.insert(0, str(HERE.parent / "hfmarketdata" / "api"))
30 +
31 +log = logging.getLogger("migrate_coverage")
32 +
33 +
34 +def convert(batch: int, max_batches: int | None, sleep_s: float) -> tuple[int, int]:
35 + """Convert rows in id order; returns (rows converted, batches)."""
36 + from sqlalchemy import text
37 +
38 + from core.db import engine
39 + from fundamentals import coverage_store as CS
40 + from fundamentals.models import init_db
41 +
42 + init_db() # makes sure coverage_id / fund_coverage_blob exist
43 + done = batches = 0
44 + last_id = 0
45 + t0 = time.time()
46 + while True:
47 + with engine.begin() as con:
48 + rows = con.execute(text("SELECT id, coverage FROM fund_statements WHERE id > :last AND coverage IS NOT NULL "
49 + "ORDER BY id LIMIT :n"), {"last": last_id, "n": batch}).all()
50 + if not rows:
51 + break
52 + docs = []
53 + for _, cov in rows:
54 + if isinstance(cov, (bytes, bytearray)):
55 + cov = cov.decode()
56 + docs.append(json.loads(cov) if isinstance(cov, str) else (cov or {}))
57 + ids = CS.intern(docs)
58 + con.execute(text("UPDATE fund_statements SET coverage = NULL, coverage_id = :cid WHERE id = :id"),
59 + [{"cid": cid, "id": rid} for (rid, _), cid in zip(rows, ids)])
60 + last_id = int(rows[-1][0])
61 + done += len(rows)
62 + batches += 1
63 + if batches % 20 == 0:
64 + log.info("converted %d rows (last id %d) in %.0fs", done, last_id, time.time() - t0)
65 + if max_batches is not None and batches >= max_batches:
66 + break
67 + if sleep_s:
68 + time.sleep(sleep_s)
69 + return done, batches
70 +
71 +
72 +def main() -> int:
73 + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
74 + ap.add_argument("--batch", type=int, default=2000, help="rows per transaction (default 2000)")
75 + ap.add_argument("--max-batches", type=int, help="stop after N batches (resume later)")
76 + ap.add_argument("--sleep", type=float, default=0.0, help="pause between batches (seconds) to stay gentle with the API")
77 + ap.add_argument("--vacuum", action="store_true", help="after conversion: auto_vacuum=INCREMENTAL + VACUUM (exclusive, slow)")
78 + ap.add_argument("-v", "--verbose", action="store_true")
79 + args = ap.parse_args()
80 + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
81 +
82 + from sqlalchemy import text
83 +
84 + from core.config import settings
85 + from core.db import engine
86 +
87 + log.info("database: %s", settings.state_db)
88 + t0 = time.time()
89 + done, batches = convert(args.batch, args.max_batches, args.sleep)
90 + with engine.connect() as con:
91 + left = con.execute(text("SELECT count(*) FROM fund_statements WHERE coverage IS NOT NULL")).scalar()
92 + blobs = con.execute(text("SELECT count(*) FROM fund_coverage_blob")).scalar()
93 + log.info("converted %d rows in %d batches (%.0fs); rows still inline: %d; distinct blobs: %d", done, batches,
94 + time.time() - t0, left, blobs)
95 + if args.vacuum:
96 + t1 = time.time()
97 + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as con:
98 + before = con.execute(text("PRAGMA page_count")).scalar()
99 + con.execute(text("PRAGMA auto_vacuum=INCREMENTAL"))
100 + con.execute(text("VACUUM"))
101 + after = con.execute(text("PRAGMA page_count")).scalar()
102 + mode = con.execute(text("PRAGMA auto_vacuum")).scalar()
103 + log.info("VACUUM: %s → %s pages (auto_vacuum=%s) in %.0fs", before, after, mode, time.time() - t1)
104 + return 0 if left == 0 or args.max_batches else 1
105 +
106 +
107 +if __name__ == "__main__":
108 + sys.exit(main())
added scripts/migrate_edgar_filings.py +71 −0
@@ -0,0 +1,71 @@
1 +#!/usr/bin/env python3
2 +"""One-off migration: `edgar_filings` primary key `accn` → `(cik, accn)` (co-registrants share accessions).
3 +
4 + # production (M3U96b) — BEFORE deploying the code that expects the composite key; stop the writers first:
5 + pm2 stop hfmarketdata-edgar-incremental hfmarketdata-edgar-backfill
6 + cd ~/hfmarketdata && HFMD_DATA_ROOT=~/firstratedata venv/bin/python scripts/migrate_edgar_filings.py
7 + pm2 start hfmarketdata-edgar-incremental
8 +
9 +The API may keep serving during the copy (WAL: readers are not blocked; a write to edgar_filings would wait on
10 +the busy timeout, hence stopping the ingestion jobs). The table is rebuilt in ONE transaction
11 +(`edgar_filings_new` ← INSERT OR IGNORE, drop, rename, 4 indexes) — on failure nothing changes. Idempotent:
12 +running it on a migrated database prints the state and exits 0. `--check` only reports.
13 +
14 +Expected duration: 1.37 M rows ≈ 212 MB → copy ~5–10 s + indexes ~10–20 s on the M3 Ultra SSD (≈ 30–60 s
15 +worst case); the WAL grows by ~300 MB and is checkpointed at the end.
16 +
17 +Author: Simon-Pierre Boucher <contact@spboucher.ai>
18 +"""
19 +from __future__ import annotations
20 +
21 +import argparse
22 +import logging
23 +import sys
24 +import time
25 +from pathlib import Path
26 +
27 +HERE = Path(__file__).resolve().parent
28 +sys.path.insert(0, str(HERE.parent / "hfmarketdata" / "api"))
29 +
30 +log = logging.getLogger("migrate_edgar_filings")
31 +
32 +
33 +def main() -> int:
34 + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
35 + ap.add_argument("--check", action="store_true", help="report the current key shape and exit")
36 + ap.add_argument("-v", "--verbose", action="store_true")
37 + args = ap.parse_args()
38 + logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
39 +
40 + from sqlalchemy import text
41 +
42 + from core.config import settings
43 + from core.db import engine
44 + from fundamentals import migrations
45 +
46 + log.info("database: %s", settings.state_db)
47 + with engine.connect() as con:
48 + state = migrations.filings_pk_is_composite(con)
49 + n = migrations.filings_row_count(con) if state is not None else 0
50 + if state is None:
51 + log.info("edgar_filings does not exist yet — nothing to migrate (init_db will create it with the composite key)")
52 + return 0
53 + if state:
54 + log.info("edgar_filings already keyed on (cik, accn) — %d rows, nothing to do", n)
55 + return 0
56 + log.info("edgar_filings keyed on accn alone: %d rows to copy", n)
57 + if args.check:
58 + return 2
59 + t0 = time.time()
60 + with engine.begin() as con:
61 + con.execute(text("PRAGMA busy_timeout=600000"))
62 + copied = migrations.migrate_filings_pk(con)
63 + with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as con:
64 + con.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
65 + ok = migrations.filings_pk_is_composite(con)
66 + log.info("done: %d rows copied (%d dropped as exact duplicates), composite key: %s, %.1fs", copied, n - copied, ok, time.time() - t0)
67 + return 0 if ok else 1
68 +
69 +
70 +if __name__ == "__main__":
71 + sys.exit(main())
added tests/test_fundamentals_ingest.py +334 −0
@@ -0,0 +1,334 @@
1 +"""Ingestion guards: co-registrant filings (shared accessions), poller termination (form filter, Redis seen-set,
2 +bounded retries), failure samples, conditional statement rewrite, coverage dictionary, schema migrations."""
3 +from __future__ import annotations
4 +
5 +import types
6 +from datetime import date
7 +
8 +import httpx
9 +import pytest
10 +from sqlalchemy import delete, select
11 +
12 +PARENT, SUB = 65984, 66901 # Entergy Corp + Entergy Arkansas: one 10-Q/8-K, two registrants
13 +SHARED = "0000065984-26-000283"
14 +OTHER = "0000065984-26-000290"
15 +
16 +
17 +def _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>")
30 +
31 +
32 +class FakeClient:
33 + """Minimal EdgarClient stand-in for poll_new_filings: canned Atom feed, optional daily index."""
34 +
35 + def __init__(self, atom: str, index: str | None = None):
36 + self.atom, self.index = atom, index
37 + self.stats = types.SimpleNamespace(requests=0)
38 + self.index_calls: list[date] = []
39 +
40 + def atom_current(self, form: str = "") -> str:
41 + self.stats.requests += 1
42 + return self.atom
43 +
44 + def daily_index(self, d: date) -> str | None:
45 + self.stats.requests += 1
46 + self.index_calls.append(d)
47 + return self.index
48 +
49 +
50 +@pytest.fixture
51 +def cofilers(fundamentals_data):
52 + """Two temporary tracked companies sharing accessions; everything they touch is removed afterwards."""
53 + from core.db import session
54 + from fundamentals import ingest
55 + from fundamentals.models import EdgarCompany, EdgarFiling, FundIngestState
56 + from stream.broker import get_redis
57 + 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 None
62 + 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_statements
68 + 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)
77 +
78 +
79 +def _stub(monkeypatch, outcome):
80 + """Replace ingest_company with a recorder; `outcome(cik)` → error string or None."""
81 + from fundamentals import ingest
82 + calls: list[int] = []
83 +
84 + 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 None
89 + return res
90 + monkeypatch.setattr(ingest, "ingest_company", fake)
91 + return calls
92 +
93 +
94 +# ------------------------------------------------------------------------------------- co-registrants
95 +def test_shared_accession_is_one_row_per_registrant(cofilers):
96 + import pandas as pd
97 +
98 + from fundamentals import ingest
99 + from fundamentals import normalize as N
100 + from fundamentals import service as S
101 + 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()) == 2
105 + assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 1 # no IntegrityError any more
106 + assert ingest._upsert_filings(SUB, {SHARED: shared}, pd.DataFrame()) == 0 # idempotent
107 + 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 links
111 + 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]) == 2
116 + assert len(S.filings("EAL", form=None, date_from=None, date_to=None)[0]) == 1
117 +
118 +
119 +def test_poll_ingests_each_coregistrant_once(cofilers, monkeypatch):
120 + from fundamentals import ingest
121 + 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: ignored
124 + ("8-K", "Nobody Inc", 999999999, "0000999999-26-000001")]) # not a tracked CIK
125 + 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) == 2
130 + # the stub recorded nothing in edgar_filings: without the Redis guard the same pairs would be "new" for ever
131 + 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) == 2
133 + assert cofilers["redis"].scard(ingest.SEEN_KEY) == 2 and cofilers["redis"].ttl(ingest.SEEN_KEY) > 6 * 86_400
134 +
135 +
136 +def 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 ingest
139 + from fundamentals.edgar_client import TRACKED_FORMS
140 + 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]
145 +
146 +
147 +def test_transient_failures_are_retried_then_abandoned(cofilers, monkeypatch):
148 + from core.db import session
149 + from fundamentals import ingest
150 + from fundamentals.models import FundIngestState
151 + 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 0
156 + 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 seen
158 + assert calls.count(SUB) == 1 and calls.count(PARENT) == ingest.MAX_ATTEMPTS
159 + 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_ATTEMPTS
164 + # samples survive the clean 4th cycle and carry the CIK
165 + 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 seen
167 +
168 +
169 +def test_no_facts_is_permanent_not_a_failure(cofilers, monkeypatch):
170 + from core.db import session
171 + from fundamentals import ingest
172 + from fundamentals.models import FundIngestState
173 + 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 0
178 + 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) == failures0
183 +
184 +
185 +def 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 ingest
188 + 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_FACTS
193 + 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 == 2
197 + assert ingest._known_accns(SUB) == {SHARED, OTHER}
198 +
199 +
200 +# ------------------------------------------------------------------------------------- daily index
201 +def test_business_days_back():
202 + from fundamentals.ingest import business_days_back
203 + assert business_days_back(date(2026, 9, 6)) == [date(2026, 9, 4), date(2026, 9, 3)] # Sunday → Fri, Thu
204 + assert business_days_back(date(2026, 9, 7)) == [date(2026, 9, 7), date(2026, 9, 4)] # Monday → Mon, Fri
205 + assert business_days_back(date(2026, 9, 9), 3) == [date(2026, 9, 9), date(2026, 9, 8), date(2026, 9, 7)]
206 +
207 +
208 +def 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 ec
211 + hits: list[str] = []
212 +
213 + 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 None
223 + 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_index
226 + 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")
230 +
231 +
232 +# ------------------------------------------------------------------------------------- statements / coverage
233 +def test_replace_statements_only_rewrites_on_change(fundamentals_data):
234 + from core.db import session
235 + from fundamentals import ingest
236 + from fundamentals import service as S
237 + from fundamentals.models import fund_statements
238 + cik = 320193
239 + 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 → untouched
243 + with session() as s:
244 + assert sorted(s.scalars(select(fund_statements.c.id).where(fund_statements.c.cik == cik))) == ids0
245 + 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.0
248 + assert ingest._replace_statements(cik, changed) is True
249 + again = S._rows(cik)
250 + assert sorted(r["id"] for r in again) != ids0
251 + 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 set
254 + 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 tests
257 + assert S._rows(cik)[0]["coverage"] == original[0]["coverage"]
258 +
259 +
260 +def test_coverage_dictionary_interns_and_hydrates(app):
261 + from core.db import session
262 + from fundamentals import coverage_store as CS
263 + from fundamentals.models import FundCoverageBlob
264 + 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 order
266 + 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-inserted
270 + 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 reference
274 + {"accn": "y", "coverage": {"revenue": {"tag": "us-gaap:Revenues"}}, "coverage_id": None}, # legacy inline
275 + {"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)}
280 +
281 +
282 +def test_failure_samples_append_and_cap(app):
283 + from core.db import session
284 + from fundamentals import ingest
285 + from fundamentals.models import FundIngestState
286 + key = "test_samples"
287 + try:
288 + ingest._set_state(key, failures_add=2, failure_samples_add=[f"e{i}" for i in range(12)])
289 + ingest._set_state(key, last_run_at=None) # a clean cycle keeps the evidence
290 + with session() as s:
291 + st = s.get(FundIngestState, key)
292 + assert st.failures == 2 and st.failure_samples == [f"e{i}" for i in range(2, 12)]
293 + ingest._set_state(key, failure_samples=[]) # explicit replace still possible
294 + with session() as s:
295 + assert s.get(FundIngestState, key).failure_samples == []
296 + finally:
297 + with session() as s:
298 + s.execute(delete(FundIngestState).where(FundIngestState.key == key))
299 +
300 +
301 +# ------------------------------------------------------------------------------------- migrations
302 +def test_migrate_filings_pk_from_legacy_table(tmp_path):
303 + from sqlalchemy import create_engine, text
304 +
305 + from fundamentals import migrations
306 + eng = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}")
307 + with eng.begin() as con:
308 + con.execute(text("""CREATE TABLE edgar_filings (accn VARCHAR(24) NOT NULL, cik INTEGER NOT NULL, form VARCHAR(16) NOT NULL,
309 + filed_date DATE NOT NULL, period_of_report DATE, primary_doc VARCHAR(512), is_amendment BOOLEAN NOT NULL,
310 + is_xbrl BOOLEAN NOT NULL, parsed_at DATETIME, PRIMARY KEY (accn))"""))
311 + con.execute(text("CREATE INDEX ix_edgar_filings_cik ON edgar_filings (cik)"))
312 + con.execute(text("INSERT INTO edgar_filings VALUES ('0000065984-26-000283', 65984, '10-Q', '2026-07-31', '2026-06-30', NULL, 0, 1, NULL)"))
313 + con.execute(text("INSERT INTO edgar_filings VALUES ('0000320193-24-000069', 320193, '10-Q', '2024-05-03', '2024-03-30', NULL, 0, 1, NULL)"))
314 + assert migrations.filings_pk_is_composite(con) is False
315 + assert migrations.migrate_filings_pk(con) == 2
316 + assert migrations.filings_pk_is_composite(con) is True
317 + # the subsidiary's copy of the shared accession now fits
318 + 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)"))
319 + assert con.execute(text("SELECT count(*) FROM edgar_filings WHERE accn='0000065984-26-000283'")).scalar() == 2
320 + names = {r[0] for r in con.execute(text("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='edgar_filings'"))}
321 + assert {"ix_edgar_filings_accn", "ix_edgar_filings_cik_filed", "ix_edgar_filings_form", "ix_edgar_filings_filed_date"} <= names
322 + assert migrations.migrate_filings_pk(con) == 3 # idempotent rerun keeps every row
323 +
324 +
325 +def test_startup_schema_is_idempotent(fundamentals_data):
326 + from core.db import engine
327 + from fundamentals import migrations
328 + assert migrations.ensure_filings_schema() == "ok"
329 + assert migrations.ensure_statements_schema() is False
330 + assert migrations.ensure_auto_vacuum() is False # already switched when the test DB was young
331 + with engine.connect() as con:
332 + from sqlalchemy import text
333 + assert int(con.execute(text("PRAGMA auto_vacuum")).scalar()) == 2
334 + migrations.incremental_vacuum()
335