|
1 |
+# Fundamentals (SEC EDGAR) — architecture, schema, ingestion, point-in-time |
|
2 |
+ |
|
3 |
+Chantier 7 of the v2 upgrade. Module `hfmarketdata/api/fundamentals/` (router `/v1/fundamentals`), plus |
|
4 |
+`stream/` (WebSocket `/v1/stream`) and `bulk/` (`/v1/bulk/fundamentals/{year}.parquet`). Data source: the SEC |
|
5 |
+EDGAR XBRL APIs (`companyfacts`, `submissions`), never proxied live to users. Ratio formulas: |
|
6 |
+[fundamentals-ratios.md](fundamentals-ratios.md). Stream protocol: [asyncapi.yaml](asyncapi.yaml). |
|
7 |
+ |
|
8 |
+## 1. Architecture |
|
9 |
+ |
|
10 |
+``` |
|
11 |
+ EDGAR (data.sec.gov / www.sec.gov) ≤ 10 req/s token bucket, backoff 429/503, UA = settings.sec_user_agent |
|
12 |
+ │ companyfacts + submissions (+ MetaLinks.json of the latest 10-K, Atom feed, daily index) |
|
13 |
+ ▼ |
|
14 |
+ data_root/edgar/raw/**.json.gz raw cache (replayable offline, one file per document) |
|
15 |
+ │ normalize.facts_frame |
|
16 |
+ ▼ |
|
17 |
+ data_root/edgar/facts/cik={cik}/facts.parquet RAW FACTS LAKE (DuckDB) — every fact instance, versioned by (accn, filed) |
|
18 |
+ │ normalize.normalize_company (mapping.py priority lists, fiscal calendar, versions, derivation) |
|
19 |
+ ▼ |
|
20 |
+ SQLite (core.db): edgar_companies · edgar_filings · fund_statements (wide, versioned) · fund_mapping · |
|
21 |
+ fund_mapping_log · fund_coverage · fund_ingest_state · fund_latest (screener) |
|
22 |
+ │ │ |
|
23 |
+ ▼ ▼ |
|
24 |
+ /v1/fundamentals/* (service.py, DuckDB asof join with the price lake for ratios) Redis `filings` → /v1/stream |
|
25 |
+ data_root/bulk/fundamentals_{year}.parquet (bulk/build.py, ETag/304, quota exempt) |
|
26 |
+``` |
|
27 |
+ |
|
28 |
+Why two stores: |
|
29 |
+ |
|
30 |
+* **Parquet + DuckDB for facts** — ~25 000 facts per large filer, ~200 M for the universe; columnar, cheap to |
|
31 |
+ rescan when the mapping changes (re-normalisation never re-downloads), `view=as_reported` and |
|
32 |
+ `/facts/{concept}` read it directly with predicate pushdown. Never mutated: facts are appended by filing. |
|
33 |
+* **SQLite for standardized statements** — needs indexes for point-in-time lookups (`ticker, period_end, |
|
34 |
+ filed_date`), small (≈ 1–2 M rows for 7 600 companies × 15 years × 3 statements × ~1.2 versions), transactional |
|
35 |
+ replace per company, and the screener table lives next to it. `coverage` JSON per row explains every null. |
|
36 |
+ |
|
37 |
+## 2. Schema (SQLite, `fundamentals/models.py`) |
|
38 |
+ |
|
39 |
+| table | key | purpose | |
|
40 |
+|---|---|---| |
|
41 |
+| `edgar_companies` | `cik` | `ticker` (canonical = first SEC listing), `tickers` (all share classes: `["GOOGL","GOOG"]`), name, `sic`, `exchange`, `fiscal_year_end` (MMDD), `status` active/delisted, `ticker_history` JSON, `facts_updated_at`, `normalized_at` | |
|
42 |
+| `edgar_filings` | `accn` | cik, form, filed_date, period_of_report, primary_doc (full EDGAR URL), is_amendment, is_xbrl, parsed_at — index (cik, filed_date) | |
|
43 |
+| `fund_statements` | id; unique (cik, statement, fiscal_year, fiscal_quarter, accn) | **wide, versioned**: cik, ticker, statement (income/balance/cashflow), fiscal_year, fiscal_quarter (1–4, **0 = annual**), period_start, period_end, calendar_quarter (`2024Q1`), form, accn, filed_date, derived, restated, currency, coverage JSON, mapping_version + one REAL column per account (49). Indexes: point-in-time `(ticker, period_end, filed_date)`, `(cik, statement, fiscal_year, fiscal_quarter, filed_date)`, `(statement, fiscal_quarter, calendar_quarter)` for frames | |
|
44 |
+| `fund_mapping` | id; unique (version, account, taxonomy, tag) | the prioritized mapping seeded from `mapping.py` — **183 rows** (49 accounts, of which 5 computed and 2 auxiliary), version `2026.09.1` (`GET /v1/fundamentals/_mapping`) | |
|
45 |
+| `fund_mapping_log` | (cik, taxonomy, tag) | tags seen but not mapped: standard-taxonomy tags outside the mapping (`is_extension=false`, occurrences, first/last seen, sample accn) and **company extensions** found in the statements of the latest 10-K via `MetaLinks.json` (`is_extension=true`, `hint_account`) | |
|
46 |
+| `fund_coverage` | ticker | first/last period, quarters, annuals, filings, completeness % (overall + per statement), `missing_accounts` {account: reason, periods}, `gaps` (missing fiscal quarters), derived/restated counts, extensions logged | |
|
47 |
+| `fund_ingest_state` | key (backfill/incremental/reconcile) | last run/success, last RSS check, `lag_seconds`, companies total/done, failures + samples, `mapping_failure_rate`, requests, events published — served by `GET /v1/fundamentals/_health` | |
|
48 |
+| `fund_latest` | ticker | precomputed screener row: identity, price/price_date, TTM flows + latest balances (public accounts) + every ratio, `reasons` JSON, `shares_source` | |
|
49 |
+ |
|
50 |
+Raw facts lake columns: `cik, taxonomy, tag, unit, fy, fp, form, start, end, val, accn, filed, frame` |
|
51 |
+(de-duplicated on `(taxonomy, tag, unit, start, end, accn)` keeping the latest `filed`). |
|
52 |
+ |
|
53 |
+## 3. Standard chart of accounts and mapping (`mapping.py`) |
|
54 |
+ |
|
55 |
+Exactly the spec's accounts — income (16), balance (19), cash flow (12) — plus two **auxiliary** inputs that are |
|
56 |
+stored but not part of the public chart: `depreciation_amortization` (for `ebitda`) and `shares_outstanding` |
|
57 |
+(dei cover-page shares, all classes summed, for `market_cap`). Computed accounts (`*` in the spec): `ebitda`, |
|
58 |
+`total_debt`, `net_debt`, `working_capital`, `free_cash_flow` — never read from a tag, formula in `coverage`. |
|
59 |
+ |
|
60 |
+Each account lists its tags **in priority order**; the first tag with a fact for the period wins and the chosen |
|
61 |
+tag + priority are written in `coverage[account]`. Examples (full table: `/v1/fundamentals/_mapping`): |
|
62 |
+ |
|
63 |
+| account | priority list (us-gaap unless noted) | why the fallbacks | |
|
64 |
+|---|---|---| |
|
65 |
+| revenue | Revenues → RevenueFromContractWithCustomerExcludingAssessedTax → SalesRevenueNet → RevenueFromContractWithCustomerIncludingAssessedTax → SalesRevenueGoodsNet → SalesRevenueServicesNet → RevenuesNetOfInterestExpense → InterestAndDividendIncomeOperating → RegulatedAndUnregulatedOperatingRevenue → OperatingLeasesIncomeStatementLeaseRevenue → ifrs-full:Revenue | ASC 606 (2018) replaced SalesRevenueNet; banks/utilities/REITs use their own top line | |
|
66 |
+| net_income | NetIncomeLoss → NetIncomeLossAvailableToCommonStockholdersBasic → ProfitLoss → IncomeLossFromContinuingOperations → ifrs-full:ProfitLossAttributableToOwnersOfParent | ProfitLoss includes NCI, used when the parent figure is absent | |
|
67 |
+| cash_and_equivalents | CashAndCashEquivalentsAtCarryingValue → CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents → Cash → CashAndDueFromBanks → CashCashEquivalentsAndShortTermInvestments | ASU 2016-18 presentation incl. restricted cash | |
|
68 |
+| short_term_debt | DebtCurrent → LongTermDebtAndCapitalLeaseObligationsCurrent → LongTermDebtCurrent → ShortTermBorrowings → CommercialPaper → NotesPayableCurrent | when DebtCurrent is absent the disjoint components are **summed** (`components: true`); Apple = CommercialPaper + LongTermDebtCurrent | |
|
69 |
+| sga_expense | SellingGeneralAndAdministrativeExpense → G&A + Selling components | split SG&A summed, flagged | |
|
70 |
+| capex | PaymentsToAcquirePropertyPlantAndEquipment → PaymentsToAcquireProductiveAssets → PaymentsForCapitalImprovements → … | positive = outflow (EDGAR convention) | |
|
71 |
+ |
|
72 |
+Accounting identities used as fallbacks (flagged `identity: true`): `gross_profit = revenue − cost_of_revenue`, |
|
73 |
+`total_liabilities = LiabilitiesAndStockholdersEquity − total_equity` (else `total_assets − total_equity`), |
|
74 |
+`operating_income = revenue − CostsAndExpenses`. Units: USD raw (not thousands), `shares`, `USD/share`; a |
|
75 |
+non-USD filer keeps its currency in `currency` and `coverage[account].currency` (20-F: ifrs-full tags are in |
|
76 |
+the lists). Extension tags (`aapl:`, `shak:`…) are **logged, never guessed**. |
|
77 |
+ |
|
78 |
+## 4. Normalisation (`normalize.py`) |
|
79 |
+ |
|
80 |
+1. **Facts → frame**, de-dup by `(tag, unit, start, end, accn)` keeping the latest filed. |
|
81 |
+2. **Fiscal calendar.** The `fy`/`fp` fields of a companyfacts fact describe the *filing*, not the fact |
|
82 |
+ (Apple's 10-Q for Q2 FY2025 re-reports Q2 FY2024 with `fy=2025, fp=Q2`). So the fiscal period of every fact |
|
83 |
+ is derived from its `end` against the fiscal year ends learnt from the 10-K report dates |
|
84 |
+ (`FiscalCalendar`, ±7-day snap for 52/53-week filers, MMDD extrapolation for years without a 10-K) and |
|
85 |
+ `fy`/`fp` are only used as a **sanity check on the filing's own period** (mismatches are counted in |
|
86 |
+ `stats.fiscal_mismatches`; 0 for the three prototypes). Apple Q2 FY2024: `end=2024-03-30`, FY end |
|
87 |
+ 2024-09-28 → 182 days → quarter 4 − round(182/91.3) = **2**. `calendar_quarter` comes from `period_end` |
|
88 |
+ (an end in the first 7 days of a month belongs to the previous month: 2025-01-03 → 2024Q4). |
|
89 |
+3. **Span classification** of duration facts: 75–105 days = quarter, 165–195 = 6-month YTD, 255–290 = 9-month |
|
90 |
+ YTD, 340–380 = fiscal year; anything else (stub periods) is ignored. |
|
91 |
+4. **Resolution per filing** (accession) and period → `Resolved` values + coverage (section 3). |
|
92 |
+5. **Versioning.** Filings are replayed in `filed` order. A period gets a new row only when a filing changes or |
|
93 |
+ completes what was known (`restated=true` when a previously served number changed; same-accession |
|
94 |
+ reported + derived pieces are one version). A later filing that re-reports a period with a *lower-priority* |
|
95 |
+ concept (cash incl. restricted cash in a comparative column) does **not** override the better concept. |
|
96 |
+ Unique key `(cik, statement, fiscal_year, fiscal_quarter, accn)`. |
|
97 |
+6. **Derivation** (flagged `derived=true`, formula in `coverage[account].derived`): cash-flow statements in 10-Qs |
|
98 |
+ are year-to-date only, income statements often carry 9-month YTD facts — `Q2 = YTD6 − Q1`, |
|
99 |
+ `Q3 = YTD9 − YTD6` (or `YTD9 − Q1 − Q2`), `Q4 = FY − YTD9` or `FY − (Q1+Q2+Q3)`; only when every input |
|
100 |
+ belongs to the same fiscal year, same unit, and was known at that filing date. Weighted share counts use |
|
101 |
+ `4×FY − (Q1+Q2+Q3)` (`approx: true`); EPS Q4 = FY − ΣQ (`approx`). Nothing is derived when a quarter is missing. |
|
102 |
+7. **Computed accounts** (`ebitda`, `total_debt`, `net_debt`, `working_capital`, `free_cash_flow`) are attached |
|
103 |
+ to the latest version of the period with their formula; assumptions are flagged |
|
104 |
+ (`short_term_debt_assumed_zero`, `short_term_investments_assumed_zero`). |
|
105 |
+8. **TTM** (`ttm()`): sum of the last four *consecutive* fiscal quarters for flows, latest quarter for balances |
|
106 |
+ and share counts, EPS = sum of four quarterly EPS (`approx`). Windows with a hole are skipped |
|
107 |
+ (`missing_quarters` reason). |
|
108 |
+ |
|
109 |
+### Point-in-time (anti look-ahead) |
|
110 |
+ |
|
111 |
+Every row carries the `filed_date` of the filing that made it known. Serving "latest" = the version with the max |
|
112 |
+`filed_date` per (statement, fiscal_year, fiscal_quarter). `as_of=D` = the same selection restricted to |
|
113 |
+`filed_date ≤ D` (`normalize.select_as_of`). Consequences: |
|
114 |
+ |
|
115 |
+* Apple's Q2 FY2024 income statement does not exist on 2024-05-02 and exists on 2024-05-03 (filing date). |
|
116 |
+* a 10-K/A or a later 10-K that restates a quarter creates a **new version**; `as_of` before the amendment returns |
|
117 |
+ the original numbers, after it the restated ones (`restated=true`). |
|
118 |
+* `/ratios?as_of=D` uses the fundamentals known at D **and** the last close at or before D; |
|
119 |
+ `/ratios/daily` builds one snapshot per `filed_date` and ASOF-joins it to the daily closes in DuckDB |
|
120 |
+ (`p.date >= s.valid_from`), so a 10-Q filed May 3 only affects May 3 onwards. |
|
121 |
+* `frames/{concept}?as_of=D` and the bulk files keep `accn`/`filed_date` for the same reason. |
|
122 |
+ |
|
123 |
+## 5. Worked example — Apple, Q2 FY2024 (10-Q filed 2024-05-03, quarter ended 2024-03-30) |
|
124 |
+ |
|
125 |
+`GET /v1/fundamentals/AAPL/statements?statement=income&period=quarterly&from=2024-03-30&to=2024-03-30` |
|
126 |
+ |
|
127 |
+| account | value | provenance (`coverage`) | |
|
128 |
+|---|---|---| |
|
129 |
+| revenue | 90 753 000 000 | `us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax` (priority 2 — Apple does not tag `Revenues`) | |
|
130 |
+| cost_of_revenue | 48 482 000 000 | `CostOfGoodsAndServicesSold` | |
|
131 |
+| gross_profit | 42 271 000 000 | `GrossProfit` | |
|
132 |
+| operating_income | 27 900 000 000 | `OperatingIncomeLoss` | |
|
133 |
+| net_income | 23 636 000 000 | `NetIncomeLoss` | |
|
134 |
+| eps_diluted | 1.53 | `EarningsPerShareDiluted` (USD/shares) | |
|
135 |
+| shares_diluted | 15 464 709 000 | `WeightedAverageNumberOfDilutedSharesOutstanding` | |
|
136 |
+| ebitda | 30 736 000 000 | computed `operating_income + depreciation_amortization` (2 836 M from the cash-flow statement) | |
|
137 |
+| interest_expense, dividends_paid | null | `reason: no_mapped_tag` (Apple stopped tagging interest expense in FY2023) | |
|
138 |
+ |
|
139 |
+Balance sheet (same period): total_assets **337 411 000 000**, cash 32 695 M, short-term investments 34 455 M, |
|
140 |
+short_term_debt 12 759 M (`CommercialPaper` + `LongTermDebtCurrent`, `components: true`), long_term_debt |
|
141 |
+91 831 M, total_debt 104 590 M (computed), total_equity 74 194 M, shares_outstanding 15 334 082 000 (cover page |
|
142 |
+2024-04-19). |
|
143 |
+ |
|
144 |
+Cash flow Q2 FY2024 (`derived=true`): operating_cash_flow **22 690 000 000** = YTD6 62 585 M − Q1 39 895 M |
|
145 |
+(`derived: "YTD6-Q1"`), capex 1 996 M, free_cash_flow 20 694 M (computed). |
|
146 |
+ |
|
147 |
+Q4 FY2024 (derived from the 10-K filed 2024-11-01): revenue 94 930 M = FY 391 035 M − YTD9 296 105 M. |
|
148 |
+TTM at Q2 FY2024: revenue 381 623 M, net income 100 389 M, diluted EPS 6.43 (sum of 4 quarters). |
|
149 |
+ |
|
150 |
+`GET /v1/fundamentals/AAPL/ratios?as_of=2024-05-03` (real close 183.38 on 2024-05-03): market_cap = |
|
151 |
+183.38 × 15 334 082 000 = 2 812 T; pe = 183.38 / 6.43 = 28.5; pb = 37.9; gross_margin = 173 966 / 381 623 = 45.6 %; |
|
152 |
+net_margin 26.3 %; roe = 100 389 / 74 194 = 135 %; current_ratio = 128 416 / 123 822 = 1.04; debt_to_equity 1.41; |
|
153 |
+enterprise_value = market_cap + 104 590 M − 32 695 M − 34 455 M; forward_pe = null (`no_estimates`); |
|
154 |
+interest_coverage = null (`missing:interest_expense`); revenue_growth_yoy = 381 623 / 385 095 − 1 = −0.9 %. |
|
155 |
+(These numbers are asserted in `tests/test_fundamentals_unit.py::test_ratio_formulas_on_apple_q2_fy2024` |
|
156 |
+against the recorded fixture; in the test lake the *price* is synthetic, so the API test checks the formulas, not |
|
157 |
+the absolute valuation.) |
|
158 |
+ |
|
159 |
+## 6. Prototype results (full companyfacts, 2026-09-04) |
|
160 |
+ |
|
161 |
+| company | CIK | facts | filings | statement versions | derived rows | restated rows | completeness | extensions logged | |
|
162 |
+|---|---|---|---|---|---|---|---|---| |
|
163 |
+| Apple (FYE late Sept, 52/53 weeks) | 320193 | 25 135 | 70 | 412 | 113 | 76 | 87.2 % | 3 | |
|
164 |
+| Microsoft (FYE June 30; 10-K carries quarterly data) | 789019 | 32 671 | 68 | 458 | 60 | 120 | 85.9 % | 4 | |
|
165 |
+| Shake Shack (Russell 2000; FYE last Wednesday of Dec, 2025 → Dec 31) | 1620533 | 18 358 | 47 | 266 | 66 | 42 | 68.8 % | 9 | |
|
166 |
+ |
|
167 |
+Shake Shack was chosen as the small cap because its income statement uses the company extension |
|
168 |
+`shak:OperatingMaterialsExpense` (food and paper costs) plus `us-gaap:LaborAndRelatedExpense` / `OccupancyNet` |
|
169 |
+instead of any cost-of-revenue concept: `cost_of_revenue` and `gross_profit` are therefore **null with |
|
170 |
+`no_mapped_tag`**, the extension is logged in `fund_mapping_log` with `hint_account=cost_of_revenue` and shown |
|
171 |
+in `/coverage.custom_extensions` — exactly the "log, don't guess" behaviour. Its 52/53-week calendar |
|
172 |
+(2023-12-27, 2024-12-25, then a change to 2025-12-31) and multi-class shares also exercise the calendar and |
|
173 |
+dei logic. Reconciliation against a fresh EDGAR fetch: 0 discrepancies for AAPL and SHAK. |
|
174 |
+ |
|
175 |
+Latency on the test lake (TestClient, 25 runs, p95): statements 4–7 ms, ratios 7 ms, ratios/daily 1 year 30 ms |
|
176 |
+(2.5 years 51 ms), screener 3 ms, frames 3 ms, filings 3 ms, coverage 1 ms — far under the 300 ms / 1 s targets; |
|
177 |
+the screener reads a precomputed table so it stays O(rows in `fund_latest`) at universe scale. |
|
178 |
+ |
|
179 |
+## 7. Ingestion |
|
180 |
+ |
|
181 |
+* **Universe** (`ingest.sync_universe`): `company_tickers.json` + `company_tickers_exchange.json` restricted to |
|
182 |
+ the tickers present in `parquet/stock/1day/*` and `parquet/etf/1day/*`. One CIK with several tickers |
|
183 |
+ (GOOG/GOOGL, BRK-A/BRK-B) = one company, `tickers` lists the classes and any class resolves in the API. A |
|
184 |
+ ticker that leaves the SEC list is appended to `ticker_history` and the company becomes `delisted` (its data |
|
185 |
+ keeps being served). ETFs that are not SEC operating filers (SPY…) are simply absent → |
|
186 |
+ `FUNDAMENTALS_NOT_AVAILABLE`. |
|
187 |
+* **Backfill** (`scripts/edgar_backfill.py`): per CIK `companyfacts` + `submissions` (+ `MetaLinks.json` of the |
|
188 |
+ latest 10-K, 1 request) → lake → statements → coverage → screener row; manifest |
|
189 |
+ `data_root/edgar/backfill_manifest.json` makes it resumable (`ok` / `no_facts` are skipped, `error` retried; |
|
190 |
+ `--force` redoes). `--from-zip companyfacts.zip` reads the SEC bulk archive instead of the API. Ends with the |
|
191 |
+ bulk Parquet files. |
|
192 |
+* **Incremental** (`scripts/edgar_incremental.py`, every 2 min): Atom `getcurrent` feed per form |
|
193 |
+ (10-K, 10-Q, 8-K, 20-F — 4 requests) + today's/yesterday's daily master index as a safety net → new accessions |
|
194 |
+ of tracked CIKs → refetch companyfacts (cache bypass) → re-normalise → publish a `filing` event |
|
195 |
+ (Redis `filings` pub/sub + `filings:stream` capped at 1 000) → lag written to `fund_ingest_state`. The efts |
|
196 |
+ full-text search needs a query term and is therefore not used as a "list everything" source. |
|
197 |
+* **Reconcile** (`scripts/edgar_reconcile.py`): 20 random companies refetched and re-normalised in memory, |
|
198 |
+ every public account of every latest period compared to the DB (`--fix` re-ingests the differing ones); |
|
199 |
+ result in `fund_ingest_state.reconcile` and `_health`. Exit code 1 on discrepancies for alerting. |
|
200 |
+* **Monitoring**: `GET /v1/fundamentals/_health` (hidden from OpenAPI) — companies, statement versions, |
|
201 |
+ screener rows, last filed date, per-job lag/failures/`mapping_failure_rate` (share of null public accounts in |
|
202 |
+ `fund_latest`). |
|
203 |
+ |
|
204 |
+### Running on production (M3U96b) |
|
205 |
+ |
|
206 |
+```bash |
|
207 |
+ssh M3U96b |
|
208 |
+cd ~/hfmarketdata |
|
209 |
+export HFMD_DATA_ROOT=~/firstratedata # lake + edgar/ + bulk/ + state/hfmd.db ; Redis: redis://127.0.0.1:6379/0 (default) |
|
210 |
+venv/bin/pip install -r hfmarketdata/requirements.txt |
|
211 |
+ |
|
212 |
+# 1) initial backfill (resumable; ~7 600 CIKs) |
|
213 |
+nohup venv/bin/python scripts/edgar_backfill.py --workers 4 > ~/edgar_backfill.log 2>&1 & |
|
214 |
+# faster first pass: download the SEC bulk archive once (~1.3 GB) and use it for companyfacts |
|
215 |
+# curl -A "$HFMD_SEC_USER_AGENT" -o /tmp/companyfacts.zip https://www.sec.gov/Archives/edgar/daily-index/xbrl/companyfacts.zip |
|
216 |
+# venv/bin/python scripts/edgar_backfill.py --from-zip /tmp/companyfacts.zip --workers 6 |
|
217 |
+ |
|
218 |
+# 2) incremental poller under PM2 (2-minute cycle, publishes to Redis for /v1/stream) |
|
219 |
+pm2 start venv/bin/python --name edgar-incremental --cwd ~/hfmarketdata -- scripts/edgar_incremental.py |
|
220 |
+pm2 save |
|
221 |
+ |
|
222 |
+# 3) nightly reconciliation (cron 03:15) — exit code 1 = discrepancies |
|
223 |
+15 3 * * * cd ~/hfmarketdata && HFMD_DATA_ROOT=~/firstratedata venv/bin/python scripts/edgar_reconcile.py --json ~/edgar_reconcile.json >> ~/edgar_reconcile.log 2>&1 |
|
224 |
+ |
|
225 |
+# health |
|
226 |
+curl -s https://www.hfmarketdata.io/v1/fundamentals/_health | jq .data.jobs |
|
227 |
+``` |
|
228 |
+ |
|
229 |
+The API process needs no restart: `init_db()` is idempotent and every request reads SQLite/Parquet. |
|
230 |
+ |
|
231 |
+### Backfill duration estimate (~7 600 CIKs at 10 req/s) |
|
232 |
+ |
|
233 |
+Requests: 2 per CIK (companyfacts + submissions) + 1 MetaLinks + ~0.3 for paginated older submissions ≈ 3.3 × |
|
234 |
+7 600 ≈ **25 000 requests → ~42 min** at the 10 req/s ceiling. Volume: companyfacts average ≈ 2–3 MB |
|
235 |
+(Apple 10 MB, small caps 1 MB) → ~20 GB downloaded, gzip-cached at ~2 GB. CPU: normalisation measured at |
|
236 |
+0.7–1.2 s per company (pandas, one core) → ~2 h single-threaded, ~35 min with `--workers 4` overlapping network |
|
237 |
+and CPU. **Expected wall clock: 1 h – 1 h 30 with 4 workers** (network-bound at the bucket), ≈ 45 min with |
|
238 |
+`--from-zip` (no companyfacts requests; the zip download itself takes a few minutes). Resulting sizes: facts lake |
|
239 |
+≈ 2.5 GB Parquet, SQLite ≈ 1.5 GB, bulk files ≈ 20 × 30 MB. |
|
240 |
+ |
|
241 |
+## 8. Endpoints (all `format=json|csv|parquet`, cursor pagination, `as_of`) |
|
242 |
+ |
|
243 |
+| endpoint | notes | |
|
244 |
+|---|---| |
|
245 |
+| `GET /v1/fundamentals/{ticker}/statements` | `statement=income\|balance\|cashflow\|all`, `period=quarterly\|annual\|ttm`, `from`/`to` on period_end, `view=standardized\|as_reported` | |
|
246 |
+| `GET /v1/fundamentals/{ticker}/facts/{concept}` | standardized account → point-in-time series; raw `us-gaap:Tag` → fact instances from the lake (`CONCEPT_NOT_FOUND`) | |
|
247 |
+| `GET /v1/fundamentals/{ticker}/ratios` | 45 ratios in 7 groups + `inputs`, `meta.reasons` for nulls, `period=ttm\|annual` | |
|
248 |
+| `GET /v1/fundamentals/{ticker}/ratios/daily` | daily close × fundamentals known that day (DuckDB ASOF), `fields=` | |
|
249 |
+| `GET /v1/fundamentals/{ticker}/filings` | `form=` filter, EDGAR links | |
|
250 |
+| `GET /v1/fundamentals/{ticker}/coverage` | completeness, missing accounts + reasons, gaps, custom extensions | |
|
251 |
+| `GET /v1/fundamentals/screener` | `filters=pe<15,roe>15%,market_cap>1b,ev_ebitda=5..12,exchange=Nasdaq\|NYSE`, `sort=fcf_yield:desc`, `columns=`; `request_cost=2`, `requires_key=true` | |
|
252 |
+| `GET /v1/fundamentals/frames/{concept}` | `calendar_quarter=2024Q1` or `fiscal_year=&fiscal_quarter=`; `request_cost=2` | |
|
253 |
+| `GET /v1/fundamentals/_mapping` | chart of accounts + prioritized tags (public) · `GET /_health` internal | |
|
254 |
+| `GET /v1/bulk/fundamentals` · `GET /v1/bulk/fundamentals/{year}.parquet` | quota exempt, strong ETag, `If-None-Match` → 304 | |
|
255 |
+| `GET /v1/stream` (WebSocket) · `GET /v1/stream/info` | see below | |
|
256 |
+ |
|
257 |
+Error codes added to `core/errors.py`: `FUNDAMENTALS_NOT_AVAILABLE` (404), `INVALID_FILTER` (400), |
|
258 |
+`CONCEPT_NOT_FOUND` (404), `STREAM_CONNECTION_LIMIT` (429). |
|
259 |
+ |
|
260 |
+## 9. Stream (`stream/`) |
|
261 |
+ |
|
262 |
+`GET /v1/stream` (WebSocket). Auth by `?api_key=` or `Authorization: Bearer` (keyless → JSON error then close |
|
263 |
+4001). Client `{"action":"subscribe","channel":"filings","tickers":[…]|"all","forms":[…],"resume_token":<seq>}`; |
|
264 |
+server `hello`, `subscribed`, `filing` (`ticker, cik, form, period, filed_date, url, accn, seq, summary:{revenue, |
|
265 |
+net_income, eps_diluted, total_assets, operating_cash_flow, fiscal_year, fiscal_quarter, yoy:{…}}`), `heartbeat` |
|
266 |
+every 20 s, `pong`, `error`. Max 5 concurrent sockets per key (Redis counter `stream:conns:{principal}`, 6th → |
|
267 |
+`STREAM_CONNECTION_LIMIT`, close 4029). Buffer = Redis stream `filings:stream` (MAXLEN 1 000, ids `<seq>-0`, |
|
268 |
+`seq` from `INCR filings:seq`); `resume_token` replays everything after it. Delivery polls the stream every |
|
269 |
+250 ms per socket (also gives resume for free); the `filings` pub/sub channel is published for other consumers. |
|
270 |
+Full spec: `docs/asyncapi.yaml`. |
|
271 |
+ |
|
272 |
+**Authentication hook**: `stream/auth.py` delegates to `accounts.security.{verify_api_key|resolve_api_key| |
|
273 |
+authenticate_key}(key)` when the accounts module is present; otherwise a key with the documented shape |
|
274 |
+`hfmd_live_<32 base62>` is accepted and the principal is `key:<sha256(salt+key)[:16]>`. |
|
275 |
+ |
|
276 |
+**Stream accounting interface** (for the ratelimit module): |
|
277 |
+ |
|
278 |
+```python |
|
279 |
+from stream import accounting |
|
280 |
+accounting.set_charger(fn) # fn(principal: str, rows: int) -> None ; called once per delivered `filing` message |
|
281 |
+accounting.charge(principal, rows=1) # what the socket calls (REDUCED_RATE_ROWS_PER_MESSAGE = 1) |
|
282 |
+accounting.rows_charged(principal) # default fallback counter: Redis `stream:rows:{principal}:{YYYY-MM-DD}` (24 h TTL) |
|
283 |
+``` |
|
284 |
+ |
|
285 |
+`principal` is the same `key:<id>` string the HTTP limiter uses, so stream rows can be folded into the rows |
|
286 |
+quota; heartbeats, hello/subscribed and errors are free. |
|
287 |
+ |
|
288 |
+## 10. Tests and fixtures |
|
289 |
+ |
|
290 |
+`tests/fixtures/edgar/` holds **real, trimmed** EDGAR documents (≈ 80 KB gzipped: companyfacts + submissions of |
|
291 |
+Apple, Microsoft, Shake Shack limited to mapped tags and filings since 2022, SHAK's MetaLinks tag list, one Atom |
|
292 |
+page, one daily index) recorded by `tests/fixtures/edgar/record.py`. `tests/conftest.py::edgar_mock` serves them |
|
293 |
+through `respx` — no test touches the network. The synthetic price lake gained the SHAK, GOOG and GOOGL tickers. |
|
294 |
+Suites: `test_fundamentals_unit.py` (mapping, calendar incl. Apple Q2 FY2024, resolution priority/units, |
|
295 |
+Q4 derivation, restatements/as_of, TTM, ratio formulas on Apple's real numbers, filter grammar, client backoff), |
|
296 |
+`test_fundamentals_api.py` (every endpoint in json/csv/parquet, envelopes, as_of, share classes), |
|
297 |
+`test_bulk.py` (ETag/304), `test_stream.py` (auth, subscribe, delivery, resume, heartbeat, connection limit, |
|
298 |
+accounting hook). `./.venv/bin/python -m pytest` → 53 passed. |
|
299 |
+ |
|
300 |
+## 11. Limitations and next steps |
|
301 |
+ |
|
302 |
+* Companyfacts only carries standard taxonomies: values reported **exclusively** through company extensions |
|
303 |
+ (SHAK cost lines, many bank/insurer line items) stay null with a reason; the `fund_mapping_log` hints are the |
|
304 |
+ input for the next mapping version (bump `MAPPING_VERSION`, re-run normalisation from the cached raw JSON). |
|
305 |
+* Dimensional facts (segments, share classes) are not in companyfacts; `shares_outstanding` sums the dei cover |
|
306 |
+ facts, which is right for market cap but not per class. |
|
307 |
+* `forward_pe` is always null (no consensus estimates). Growth/CAGR need enough history (null otherwise). |
|
308 |
+* Fiscal-year labels of Jan–Mar year ends follow the filer's convention learnt from its 10-Ks; a filer with no |
|
309 |
+ 10-K in the window falls back to the MMDD extrapolation. |
|
310 |
+* Restatement detection is value-based (tolerance 1e-6 relative / 0.5 absolute); a filer re-tagging the same |
|
311 |
+ number under a lower-priority concept does not create a version, a re-tag under a *higher*-priority concept does. |
|
312 |
+* 20-F/40-F filers are mapped through ifrs-full tags but were not part of the prototype; currency is kept, not |
|
313 |
+ converted. |
|
314 |
+* The ratelimit module must call `stream.accounting.set_charger` and honour `request.state.requires_key` / |
|
315 |
+ `request_cost` / `quota_exempt` set by these routes; the WebSocket itself is not rate-limited beyond the |
|
316 |
+ 5-connection cap. |
|
317 |
|