fundamentals: tests unitaires, intégration, WebSocket et bulk + fixtures EDGAR réelles tronquées (Apple, Microsoft, Shake Shack), aucun accès réseau (respx)
- lac synthétique étendu (SHAK, GOOG, GOOGL) ; conftest : edgar_mock + fundamentals_data - 46 tests : mapping, calendrier fiscal (Apple Q2 FY2024 = 2024-03-30), priorités/unités, dérivation Q4, restatements/as_of, TTM, formules validées sur les chiffres réels d'Apple, grammaire de filtres, tous les endpoints json/csv/parquet, enveloppes 404/400, bulk 304, WebSocket (auth, abonnement, réception, resume, heartbeat, limite) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 changed files +1,053 −1
modified
tests/conftest.py
+48 −0
@@ -47,3 +47,51 @@ def client(app): | ||
| 47 | 47 | from fastapi.testclient import TestClient |
| 48 | 48 | with TestClient(app) as c: |
| 49 | 49 | yield c |
| 50 | + | |
| 51 | + | |
| 52 | +# ----------------------------------------------------------------------------------------- fundamentals | |
| 53 | +EDGAR_FIXTURES = ROOT / "tests" / "fixtures" / "edgar" | |
| 54 | +PROTO_CIKS = {"AAPL": 320193, "MSFT": 789019, "SHAK": 1620533} | |
| 55 | + | |
| 56 | + | |
| 57 | +def load_gz(name: str): | |
| 58 | + import gzip | |
| 59 | + import json | |
| 60 | + with gzip.open(EDGAR_FIXTURES / name, "rt", encoding="utf-8") as fh: | |
| 61 | + return json.load(fh) | |
| 62 | + | |
| 63 | + | |
| 64 | +@pytest.fixture(scope="session") | |
| 65 | +def edgar_mock(app): | |
| 66 | + """respx router serving the recorded EDGAR fixtures — the tests never reach sec.gov.""" | |
| 67 | + import respx | |
| 68 | + from fundamentals import edgar_client as ec | |
| 69 | + router = respx.mock(assert_all_called=False, assert_all_mocked=True) | |
| 70 | + router.get(ec.URL_COMPANY_TICKERS).respond(json=load_gz("company_tickers.json.gz")) | |
| 71 | + router.get(ec.URL_COMPANY_TICKERS_EXCHANGE).respond(json=load_gz("company_tickers_exchange.json.gz")) | |
| 72 | + for cik in PROTO_CIKS.values(): | |
| 73 | + router.get(f"{ec.DATA_BASE}/api/xbrl/companyfacts/CIK{cik:010d}.json").respond(json=load_gz(f"companyfacts_CIK{cik:010d}.json.gz")) | |
| 74 | + router.get(f"{ec.DATA_BASE}/submissions/CIK{cik:010d}.json").respond(json=load_gz(f"submissions_CIK{cik:010d}.json.gz")) | |
| 75 | + router.get(url__regex=r"https://data\.sec\.gov/api/xbrl/companyfacts/CIK\d+\.json").respond(404) | |
| 76 | + router.get(url__regex=r"https://data\.sec\.gov/submissions/CIK\d+\.json").respond(404) | |
| 77 | + router.get(url__regex=r"https://www\.sec\.gov/Archives/edgar/data/1620533/000162053326000018/MetaLinks\.json").respond( | |
| 78 | + json=load_gz("metalinks_CIK0001620533_000162053326000018.json.gz")) | |
| 79 | + router.get(url__regex=r"https://www\.sec\.gov/Archives/edgar/data/.*/MetaLinks\.json").respond(404) | |
| 80 | + router.get(url__regex=r"https://www\.sec\.gov/cgi-bin/browse-edgar.*").respond(text=(EDGAR_FIXTURES / "atom_10-Q.xml").read_text()) | |
| 81 | + router.get(url__regex=r"https://www\.sec\.gov/Archives/edgar/daily-index/.*").respond(text=(EDGAR_FIXTURES / "master.idx").read_text()) | |
| 82 | + with router: | |
| 83 | + yield router | |
| 84 | + | |
| 85 | + | |
| 86 | +@pytest.fixture(scope="session") | |
| 87 | +def fundamentals_data(app, lake, edgar_mock): | |
| 88 | + """Universe + full ingestion of the three prototype companies into the test lake / state DB.""" | |
| 89 | + from fundamentals import edgar_client, ingest | |
| 90 | + client = edgar_client.EdgarClient(rate=10_000, raw_dir=lake / "edgar" / "raw") | |
| 91 | + universe = ingest.sync_universe(client) | |
| 92 | + results = {tk: ingest.ingest_company(client, cik) for tk, cik in PROTO_CIKS.items()} | |
| 93 | + for tk, r in results.items(): | |
| 94 | + assert r.error is None, f"{tk}: {r.error}" | |
| 95 | + ingest._set_state("backfill", companies_total=len(PROTO_CIKS), companies_done=len(PROTO_CIKS)) | |
| 96 | + ingest._update_mapping_failure_rate() | |
| 97 | + return {"universe": universe, "results": results, "client": client} | |
added
tests/fixtures/edgar/atom_10-Q.xml
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +<?xml version="1.0" encoding="ISO-8859-1" ?> | |
| 2 | +<feed xmlns="http://www.w3.org/2005/Atom"> | |
| 3 | +<title>Latest Filings - Fri, 04 Sep 2026 19:23:41 EDT</title> | |
| 4 | +<link rel="alternate" href="/cgi-bin/browse-edgar?action=getcurrent"/> | |
| 5 | +<link rel="self" href="/cgi-bin/browse-edgar?action=getcurrent"/> | |
| 6 | +<id>https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent</id> | |
| 7 | +<author><name>Webmaster</name><email>webmaster@sec.gov</email></author> | |
| 8 | +<updated>2026-09-04T19:23:41-04:00</updated> | |
| 9 | +<entry> | |
| 10 | +<title>10-Q - ChargePoint Holdings, Inc. (0001777393) (Filer)</title> | |
| 11 | +<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/1777393/000162828026060770/0001628280-26-060770-index.htm"/> | |
| 12 | +<summary type="html"> | |
| 13 | + <b>Filed:</b> 2026-09-04 <b>AccNo:</b> 0001628280-26-060770 <b>Size:</b> 14 MB | |
| 14 | +</summary> | |
| 15 | +<updated>2026-09-04T17:27:57-04:00</updated> | |
| 16 | +<category scheme="https://www.sec.gov/" label="form type" term="10-Q"/> | |
| 17 | +<id>urn:tag:sec.gov,2008:accession-number=0001628280-26-060770</id> | |
| 18 | +</entry> | |
| 19 | +<entry> | |
| 20 | +<title>10-Q - Cactus Acquisition Corp. 1 Ltd (0001865861) (Filer)</title> | |
| 21 | +<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/1865861/000149315226041603/0001493152-26-041603-index.htm"/> | |
| 22 | +<summary type="html"> | |
| 23 | + <b>Filed:</b> 2026-09-04 <b>AccNo:</b> 0001493152-26-041603 <b>Size:</b> 3 MB | |
| 24 | +</summary> | |
| 25 | +<updated>2026-09-04T17:25:26-04:00</updated> | |
| 26 | +<category scheme="https://www.sec.gov/" label="form type" term="10-Q"/> | |
| 27 | +<id>urn:tag:sec.gov,2008:accession-number=0001493152-26-041603</id> | |
| 28 | +</entry> | |
| 29 | +<entry> | |
| 30 | +<title>10-Q - OOMA INC (0001327688) (Filer)</title> | |
| 31 | +<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/1327688/000132768826000020/0001327688-26-000020-index.htm"/> | |
| 32 | +<summary type="html"> | |
| 33 | + <b>Filed:</b> 2026-09-04 <b>AccNo:</b> 0001327688-26-000020 <b>Size:</b> 10 MB | |
| 34 | +</summary> | |
| 35 | +<updated>2026-09-04T17:19:51-04:00</updated> | |
| 36 | +<category scheme="https://www.sec.gov/" label="form type" term="10-Q"/> | |
| 37 | +<id>urn:tag:sec.gov,2008:accession-number=0001327688-26-000020</id> | |
| 38 | +</entry> | |
| 39 | +</feed> | |
added
tests/fixtures/edgar/company_tickers.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/company_tickers_exchange.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/companyfacts_CIK0000320193.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/companyfacts_CIK0000789019.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/companyfacts_CIK0001620533.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/master.idx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +Description: Daily Index of EDGAR Dissemination Feed | |
| 2 | +Last Data Received: Sep 3, 2026 | |
| 3 | +Comments: webmaster@sec.gov | |
| 4 | +Anonymous FTP: ftp://ftp.sec.gov/edgar/ | |
| 5 | + | |
| 6 | +CIK|Company Name|Form Type|Date Filed|File Name | |
| 7 | +-------------------------------------------------------------------------------- | |
| 8 | +1000275|ROYAL BANK OF CANADA|424B2|20260903|edgar/data/1000275/0000950103-26-013516.txt | |
| 9 | +1000275|ROYAL BANK OF CANADA|424B2|20260903|edgar/data/1000275/0000950103-26-013526.txt | |
| 10 | +1000275|ROYAL BANK OF CANADA|424B2|20260903|edgar/data/1000275/0000950103-26-013527.txt | |
| 11 | +1000275|ROYAL BANK OF CANADA|424B2|20260903|edgar/data/1000275/0000950103-26-013537.txt | |
| 12 | +100493|TYSON FOODS, INC.|8-K|20260903|edgar/data/100493/0000100493-26-000067.txt | |
| 13 | +1015155|CHARLES & COLVARD LTD|8-K|20260903|edgar/data/1015155/0001104659-26-105089.txt | |
| 14 | +1037038|RALPH LAUREN CORP|8-K|20260903|edgar/data/1037038/0000950142-26-002493.txt | |
| 15 | +1043951|CAMPBELL FUND TRUST|8-K|20260903|edgar/data/1043951/0001140361-26-035557.txt | |
| 16 | +1045810|NVIDIA CORP|8-K|20260903|edgar/data/1045810/0001045810-26-000078.txt | |
| 17 | +105132|WD 40 CO|8-K|20260903|edgar/data/105132/0000105132-26-000068.txt | |
| 18 | +1060386|BRANDYWINE OPERATING PARTNERSHIP, L.P.|8-K|20260903|edgar/data/1060386/0000790816-26-000033.txt | |
| 19 | +1066194|EGAIN Corp|8-K|20260903|edgar/data/1066194/0001066194-26-000014.txt | |
| 20 | +1071236|RED RIVER BANCSHARES INC|8-K|20260903|edgar/data/1071236/0001071236-26-000062.txt | |
| 21 | +107140|JOHN WILEY & SONS, INC.|8-K|20260903|edgar/data/107140/0001628280-26-060252.txt | |
| 22 | +107263|WILLIAMS COMPANIES, INC.|8-K|20260903|edgar/data/107263/0001193125-26-381926.txt | |
| 23 | +1074871|Modular Medical, Inc.|8-K|20260903|edgar/data/1074871/0001213900-26-097280.txt | |
added
tests/fixtures/edgar/metalinks_CIK0001620533_000162053326000018.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/record.py
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +"""Record trimmed REAL EDGAR fixtures for the fundamentals tests (run manually, needs network). | |
| 2 | + | |
| 3 | + HFMD_DATA_ROOT=/tmp/hfmd-proto .venv/bin/python tests/fixtures/edgar/record.py | |
| 4 | + | |
| 5 | +Prototype companies: Apple (CIK 320193, FYE late September, 52/53-week), Microsoft (CIK 789019, FYE June 30), | |
| 6 | +Shake Shack (CIK 1620533, Russell 2000, FYE last Wednesday of December, restaurant cost lines tagged with the | |
| 7 | +company extension `shak:OperatingMaterialsExpense` → cost_of_revenue legitimately unmapped). | |
| 8 | + | |
| 9 | +Trimming keeps the fixtures small (< 1 MB gzipped total) while staying real: | |
| 10 | +* companyfacts: mapped tags + identity tags + a few unmapped us-gaap tags, facts filed since 2022-01-01 | |
| 11 | + with period end >= 2020-09-01; | |
| 12 | +* submissions: filings of the tracked forms since 2022-01-01 (older pages dropped); | |
| 13 | +* MetaLinks of the latest 10-K (SHAK): tag names only. | |
| 14 | +""" | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import gzip | |
| 18 | +import json | |
| 19 | +import os | |
| 20 | +import sys | |
| 21 | +from datetime import date | |
| 22 | +from pathlib import Path | |
| 23 | + | |
| 24 | +HERE = Path(__file__).resolve().parent | |
| 25 | +ROOT = HERE.parents[2] | |
| 26 | +sys.path.insert(0, str(ROOT / "hfmarketdata" / "api")) | |
| 27 | +os.environ.setdefault("HFMD_DATA_ROOT", "/tmp/hfmd-proto") | |
| 28 | + | |
| 29 | +from fundamentals import mapping as M # noqa: E402 | |
| 30 | +from fundamentals.edgar_client import TRACKED_FORMS, EdgarClient # noqa: E402 | |
| 31 | + | |
| 32 | +COMPANIES = {"AAPL": 320193, "MSFT": 789019, "SHAK": 1620533} | |
| 33 | +EXTRA_TICKERS = {"GOOGL": 1652044, "GOOG": 1652044} # one CIK, two share classes (mapping test only) | |
| 34 | +MIN_END = "2020-09-01" | |
| 35 | +MIN_FILED = "2022-01-01" | |
| 36 | +KEEP_UNMAPPED = ("OperatingExpenses", "LaborAndRelatedExpense", "OccupancyNet", "PreOpeningCosts", | |
| 37 | + "ComprehensiveIncomeNetOfTax", "OtherNonoperatingIncomeExpense", "IncreaseDecreaseInInventories") | |
| 38 | + | |
| 39 | + | |
| 40 | +def dump(path: Path, obj) -> None: | |
| 41 | + path.parent.mkdir(parents=True, exist_ok=True) | |
| 42 | + with gzip.open(path, "wt", encoding="utf-8", compresslevel=9) as fh: | |
| 43 | + json.dump(obj, fh, separators=(",", ":")) | |
| 44 | + print(f" {path.name}: {path.stat().st_size / 1024:.0f} KB") | |
| 45 | + | |
| 46 | + | |
| 47 | +def trim_companyfacts(cf: dict) -> dict: | |
| 48 | + keep = {(t.taxonomy, t.tag) for a in M.ACCOUNTS for t in a.tags} | set(M.IDENTITY_TAGS) | |
| 49 | + keep |= {("us-gaap", t) for t in KEEP_UNMAPPED} | |
| 50 | + out = {"cik": cf["cik"], "entityName": cf["entityName"], "facts": {}} | |
| 51 | + for tax, tags in cf["facts"].items(): | |
| 52 | + for tag, body in tags.items(): | |
| 53 | + if (tax, tag) not in keep and tax != "dei": | |
| 54 | + continue | |
| 55 | + units = {} | |
| 56 | + for unit, facts in body["units"].items(): | |
| 57 | + kept = [f for f in facts if f["end"] >= MIN_END and f["filed"] >= MIN_FILED] | |
| 58 | + if kept: | |
| 59 | + units[unit] = kept | |
| 60 | + if units: | |
| 61 | + out["facts"].setdefault(tax, {})[tag] = {"label": body.get("label"), "description": "", "units": units} | |
| 62 | + return out | |
| 63 | + | |
| 64 | + | |
| 65 | +def trim_submissions(sub: dict) -> dict: | |
| 66 | + rec = sub["filings"]["recent"] | |
| 67 | + idx = [i for i, f in enumerate(rec["form"]) if f in TRACKED_FORMS and rec["filingDate"][i] >= MIN_FILED] | |
| 68 | + recent = {k: [v[i] for i in idx] for k, v in rec.items()} | |
| 69 | + out = {k: v for k, v in sub.items() if k not in ("filings", "addresses", "formerNames", "description")} | |
| 70 | + out["filings"] = {"recent": recent, "files": []} | |
| 71 | + return out | |
| 72 | + | |
| 73 | + | |
| 74 | +def trim_metalinks(ml: dict) -> dict: | |
| 75 | + key, inst = next(iter(ml["instance"].items())) | |
| 76 | + keep_roles = {r["role"] for r in inst["report"].values() if r.get("menuCat") == "Statements"} | |
| 77 | + tags = {} | |
| 78 | + for name, info in inst["tag"].items(): | |
| 79 | + pres = [p for p in (info.get("presentation") or []) if p in keep_roles] | |
| 80 | + tags[name] = {"xbrltype": info.get("xbrltype"), "nsuri": info.get("nsuri"), "presentation": pres} | |
| 81 | + return {"instance": {key: {"nsprefix": inst["nsprefix"], "nsuri": inst["nsuri"], "baseTaxonomies": inst["baseTaxonomies"], | |
| 82 | + "report": {k: {"role": r.get("role"), "shortName": r.get("shortName"), "menuCat": r.get("menuCat")} | |
| 83 | + for k, r in inst["report"].items() if r.get("menuCat") == "Statements"}, | |
| 84 | + "tag": tags}}} | |
| 85 | + | |
| 86 | + | |
| 87 | +def main() -> None: | |
| 88 | + c = EdgarClient() | |
| 89 | + tickers = c.company_tickers() | |
| 90 | + exch = c.company_tickers_exchange() | |
| 91 | + wanted = set(COMPANIES.values()) | set(EXTRA_TICKERS.values()) | |
| 92 | + ct = {str(i): v for i, v in enumerate(v for v in tickers.values() if int(v["cik_str"]) in wanted)} | |
| 93 | + dump(HERE / "company_tickers.json.gz", ct) | |
| 94 | + dump(HERE / "company_tickers_exchange.json.gz", {"fields": exch["fields"], | |
| 95 | + "data": [r for r in exch["data"] if int(r[0]) in wanted]}) | |
| 96 | + for tk, cik in COMPANIES.items(): | |
| 97 | + print(tk, cik) | |
| 98 | + cf = c.companyfacts(cik) | |
| 99 | + dump(HERE / f"companyfacts_CIK{cik:010d}.json.gz", trim_companyfacts(cf)) | |
| 100 | + sub = c.submissions(cik, include_older=False) | |
| 101 | + dump(HERE / f"submissions_CIK{cik:010d}.json.gz", trim_submissions(sub)) | |
| 102 | + if tk == "SHAK": | |
| 103 | + rec = sub["filings"]["recent"] | |
| 104 | + accn = next(rec["accessionNumber"][i] for i, f in enumerate(rec["form"]) if f == "10-K") | |
| 105 | + dump(HERE / f"metalinks_CIK{cik:010d}_{accn.replace('-', '')}.json.gz", trim_metalinks(c.metalinks(cik, accn))) | |
| 106 | + import re | |
| 107 | + atom = c.atom_current("10-Q") | |
| 108 | + head = atom.split("<entry>")[0] | |
| 109 | + entries = re.findall(r"<entry>.*?</entry>", atom, flags=re.S)[:3] | |
| 110 | + (HERE / "atom_10-Q.xml").write_text(head + "\n".join(entries) + "\n</feed>\n", encoding="utf-8") | |
| 111 | + yesterday = date.fromordinal(date.today().toordinal() - 1) | |
| 112 | + for back in range(0, 6): | |
| 113 | + d = date.fromordinal(yesterday.toordinal() - back) | |
| 114 | + q = (d.month - 1) // 3 + 1 | |
| 115 | + try: | |
| 116 | + txt = c.get_text(f"https://www.sec.gov/Archives/edgar/daily-index/{d.year}/QTR{q}/master.{d:%Y%m%d}.idx") | |
| 117 | + except Exception: | |
| 118 | + continue | |
| 119 | + lines = txt.splitlines() | |
| 120 | + head = lines[:11] | |
| 121 | + body = [ln for ln in lines[11:] if ln.split("|")[2:3] and ln.split("|")[2] in ("10-K", "10-Q", "8-K", "20-F")][:12] | |
| 122 | + (HERE / "master.idx").write_text("\n".join(head + body) + "\n", encoding="utf-8") | |
| 123 | + print(" master.idx from", d) | |
| 124 | + break | |
| 125 | + print(c.stats) | |
| 126 | + | |
| 127 | + | |
| 128 | +if __name__ == "__main__": | |
| 129 | + main() | |
added
tests/fixtures/edgar/submissions_CIK0000320193.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/submissions_CIK0000789019.json.gz
+0 −0
Binary file not shown.
added
tests/fixtures/edgar/submissions_CIK0001620533.json.gz
+0 −0
Binary file not shown.
modified
tests/fixtures/make_fixtures.py
+2 −1
@@ -19,7 +19,8 @@ import numpy as np | ||
| 19 | 19 | import pandas as pd |
| 20 | 20 | |
| 21 | 21 | TIMEFRAMES = ["1min", "5min", "30min", "1hour", "1day"] |
| 22 | −TICKERS = {"stock": ["AAPL", "MSFT", "SMCP"], "etf": ["SPY"], "crypto": ["BTCUSD"], "index": ["SPX"], "fx": ["EURUSD"]} | |
| 22 | +TICKERS = {"stock": ["AAPL", "MSFT", "SMCP", "SHAK", "GOOG", "GOOGL"], "etf": ["SPY"], "crypto": ["BTCUSD"], | |
| 23 | + "index": ["SPX"], "fx": ["EURUSD"]} | |
| 23 | 24 | ADJ = {"stock": ["adj_split", "adj_splitdiv", "UNADJUSTED"], "etf": ["adj_split", "adj_splitdiv", "UNADJUSTED"], |
| 24 | 25 | "crypto": ["none"], "index": ["none"], "fx": ["none"]} |
| 25 | 26 | ROOTS = ["ES", "CL", "NG"] |
added
tests/test_bulk.py
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +"""Bulk fundamentals extracts: listing, Parquet download, ETag / 304.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import io | |
| 5 | + | |
| 6 | +import pandas as pd | |
| 7 | +import pytest | |
| 8 | + | |
| 9 | +pytestmark = pytest.mark.usefixtures("fundamentals_data") | |
| 10 | + | |
| 11 | + | |
| 12 | +def test_bulk_list_and_download_with_etag(client): | |
| 13 | + r = client.get("/v1/bulk/fundamentals") | |
| 14 | + assert r.status_code == 200 | |
| 15 | + years = [y["year"] for y in r.json()["data"]] | |
| 16 | + assert 2024 in years | |
| 17 | + r = client.get("/v1/bulk/fundamentals/2024.parquet") | |
| 18 | + assert r.status_code == 200 and r.headers["content-type"] == "application/vnd.apache.parquet" | |
| 19 | + etag = r.headers["ETag"] | |
| 20 | + assert etag.startswith('"') and int(r.headers["X-Row-Count"]) > 0 | |
| 21 | + df = pd.read_parquet(io.BytesIO(r.content)) | |
| 22 | + assert set(df["ticker"]) == {"AAPL", "MSFT", "SHAK"} and set(df["fiscal_year"]) == {2024} | |
| 23 | + assert {"income", "balance", "cashflow"} == set(df["statement"]) | |
| 24 | + aapl_q2 = df[(df.ticker == "AAPL") & (df.statement == "income") & (df.fiscal_quarter == 2)] | |
| 25 | + assert len(aapl_q2) == 1 and float(aapl_q2["revenue"].iloc[0]) == 90_753_000_000 | |
| 26 | + # latest version only: one row per (company, statement, period) | |
| 27 | + assert not df.duplicated(["cik", "statement", "fiscal_year", "fiscal_quarter"]).any() | |
| 28 | + r304 = client.get("/v1/bulk/fundamentals/2024.parquet", headers={"If-None-Match": etag}) | |
| 29 | + assert r304.status_code == 304 and r304.headers["X-Row-Count"] == "0" and r304.headers["ETag"] == etag | |
| 30 | + r = client.get("/v1/bulk/fundamentals/2024.parquet", headers={"If-None-Match": '"stale"'}) | |
| 31 | + assert r.status_code == 200 | |
| 32 | + listed = next(y for y in client.get("/v1/bulk/fundamentals").json()["data"] if y["year"] == 2024) | |
| 33 | + assert listed["etag"] == etag and listed["stale"] is False | |
| 34 | + | |
| 35 | + | |
| 36 | +def test_bulk_errors(client): | |
| 37 | + r = client.get("/v1/bulk/fundamentals/1999.parquet") | |
| 38 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 39 | + r = client.get("/v1/bulk/fundamentals/2099.parquet") | |
| 40 | + assert r.status_code == 404 and r.json()["error"]["code"] == "NOT_FOUND" and "available_years" in r.json()["error"]["details"] | |
added
tests/test_fundamentals_api.py
+270 −0
@@ -0,0 +1,270 @@ | ||
| 1 | +"""Integration tests of /v1/fundamentals/* (TestClient over the ingested prototype companies).""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import io | |
| 5 | + | |
| 6 | +import pandas as pd | |
| 7 | +import pytest | |
| 8 | + | |
| 9 | +pytestmark = pytest.mark.usefixtures("fundamentals_data") | |
| 10 | + | |
| 11 | + | |
| 12 | +def _rows(body): | |
| 13 | + return body["data"] | |
| 14 | + | |
| 15 | + | |
| 16 | +def _find(rows, fy, fq, statement=None): | |
| 17 | + return next(r for r in rows if r["fiscal_year"] == fy and r["fiscal_quarter"] == fq and (statement is None or r["statement"] == statement)) | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_universe_and_share_classes(fundamentals_data): | |
| 21 | + from fundamentals.service import resolve_company | |
| 22 | + u = fundamentals_data["universe"] | |
| 23 | + assert u.companies == 4 and u.added == 4 # AAPL, MSFT, SHAK, Alphabet (GOOG+GOOGL) | |
| 24 | + goog, googl = resolve_company("GOOG"), resolve_company("GOOGL") | |
| 25 | + assert goog.cik == googl.cik == 1652044 and sorted(goog.tickers) == ["GOOG", "GOOGL"] | |
| 26 | + assert resolve_company("aapl").cik == 320193 | |
| 27 | + | |
| 28 | + | |
| 29 | +def test_statements_quarterly_json(client): | |
| 30 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=quarterly&limit=200") | |
| 31 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == str(r.json()["meta"]["count"]) | |
| 32 | + body = r.json() | |
| 33 | + assert body["meta"]["ticker"] == "AAPL" and body["meta"]["statement"] == "income" | |
| 34 | + row = _find(_rows(body), 2024, 2) | |
| 35 | + assert row["period_end"] == "2024-03-30" and row["revenue"] == 90_753_000_000 and row["net_income"] == 23_636_000_000 | |
| 36 | + assert row["eps_diluted"] == 1.53 and row["form"] == "10-Q" and row["filed_date"] == "2024-05-03" | |
| 37 | + assert row["coverage"]["interest_expense"] == {"reason": "no_mapped_tag"} | |
| 38 | + assert "shares_outstanding" not in row and "depreciation_amortization" not in row # auxiliary accounts hidden | |
| 39 | + q4 = _find(_rows(body), 2024, 4) | |
| 40 | + assert q4["derived"] is True and q4["coverage"]["revenue"]["derived"] in ("FY-YTD9", "FY-(Q1+Q2+Q3)") | |
| 41 | + # rows are newest first and only quarters | |
| 42 | + assert all(r["fiscal_quarter"] in (1, 2, 3, 4) for r in _rows(body)) | |
| 43 | + assert _rows(body)[0]["period_end"] >= _rows(body)[-1]["period_end"] | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_statements_all_statements_and_pagination(client): | |
| 47 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=all&period=quarterly&limit=5") | |
| 48 | + body = r.json() | |
| 49 | + assert body["meta"]["count"] == 5 and body["meta"]["next_cursor"] | |
| 50 | + r2 = client.get(f"/v1/fundamentals/AAPL/statements?statement=all&period=quarterly&limit=5&cursor={body['meta']['next_cursor']}") | |
| 51 | + assert r2.status_code == 200 and r2.json()["data"][0] != body["data"][0] | |
| 52 | + assert {r["statement"] for r in body["data"]} <= {"income", "balance", "cashflow"} | |
| 53 | + assert "total_assets" in body["data"][0] and "operating_cash_flow" in body["data"][0] | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_statements_annual_ttm_and_balance(client): | |
| 57 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=annual") | |
| 58 | + fy = _find(_rows(r.json()), 2024, 0) | |
| 59 | + assert fy["revenue"] == 391_035_000_000 and fy["period_start"] == "2023-10-01" and fy["period_end"] == "2024-09-28" | |
| 60 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=ttm&to=2024-03-30&limit=1") | |
| 61 | + row = _rows(r.json())[0] | |
| 62 | + assert row["ttm"] is True and row["fiscal_year"] == 2024 and row["fiscal_quarter"] == 2 and row["revenue"] == 381_623_000_000 | |
| 63 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=balance&period=quarterly&from=2024-03-30&to=2024-03-30") | |
| 64 | + bal = _rows(r.json())[0] | |
| 65 | + assert bal["total_assets"] == 337_411_000_000 and bal["period_start"] is None | |
| 66 | + assert bal["total_debt"] == bal["short_term_debt"] + bal["long_term_debt"] | |
| 67 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=cashflow&period=quarterly&from=2024-03-30&to=2024-03-30") | |
| 68 | + cf = _rows(r.json())[0] | |
| 69 | + assert cf["operating_cash_flow"] == 22_690_000_000 and cf["derived"] is True | |
| 70 | + assert cf["free_cash_flow"] == cf["operating_cash_flow"] - cf["capex"] | |
| 71 | + | |
| 72 | + | |
| 73 | +def test_statements_as_of_point_in_time(client): | |
| 74 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=quarterly&as_of=2024-05-02&limit=1") | |
| 75 | + assert r.status_code == 200 | |
| 76 | + top = _rows(r.json())[0] | |
| 77 | + assert (top["fiscal_year"], top["fiscal_quarter"]) == (2024, 1) and r.json()["meta"]["as_of"] == "2024-05-02" | |
| 78 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=quarterly&as_of=2024-05-03&limit=1") | |
| 79 | + top = _rows(r.json())[0] | |
| 80 | + assert (top["fiscal_year"], top["fiscal_quarter"]) == (2024, 2) | |
| 81 | + r = client.get("/v1/fundamentals/AAPL/statements?as_of=2000-01-01") | |
| 82 | + assert r.status_code == 404 and r.json()["error"]["code"] == "FUNDAMENTALS_NOT_AVAILABLE" | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_statements_formats(client): | |
| 86 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=annual&format=csv") | |
| 87 | + assert r.status_code == 200 and r.headers["content-type"].startswith("text/csv") | |
| 88 | + df = pd.read_csv(io.StringIO(r.text)) | |
| 89 | + assert "revenue" in df.columns and len(df) == int(r.headers["X-Row-Count"]) | |
| 90 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=annual&format=parquet") | |
| 91 | + assert r.status_code == 200 and r.headers["content-type"] == "application/vnd.apache.parquet" | |
| 92 | + df = pd.read_parquet(io.BytesIO(r.content)) | |
| 93 | + assert df["revenue"].max() > 3e11 | |
| 94 | + r = client.get("/v1/fundamentals/AAPL/statements?format=xml") | |
| 95 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 96 | + | |
| 97 | + | |
| 98 | +def test_statements_as_reported_view(client): | |
| 99 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=income&period=quarterly&view=as_reported&from=2024-03-30&to=2024-03-30") | |
| 100 | + assert r.status_code == 200 | |
| 101 | + rows = _rows(r.json()) | |
| 102 | + rev = next(x for x in rows if x["account"] == "revenue") | |
| 103 | + assert rev["tag"] == "RevenueFromContractWithCustomerExcludingAssessedTax" and rev["val"] == 90_753_000_000 | |
| 104 | + assert rev["accn"] == "0000320193-24-000069" and rev["unit"] == "USD" | |
| 105 | + | |
| 106 | + | |
| 107 | +def test_errors_envelope(client): | |
| 108 | + r = client.get("/v1/fundamentals/NOPE/statements") | |
| 109 | + assert r.status_code == 404 | |
| 110 | + body = r.json() | |
| 111 | + assert body["error"]["code"] == "FUNDAMENTALS_NOT_AVAILABLE" and body["error"]["docs"].endswith("#fundamentals_not_available") | |
| 112 | + assert body["detail"] and body["error"]["details"]["ticker"] == "NOPE" | |
| 113 | + r = client.get("/v1/fundamentals/SPY/statements") # in the price lake but not an SEC filer | |
| 114 | + assert r.status_code == 404 and r.json()["error"]["code"] == "FUNDAMENTALS_NOT_AVAILABLE" | |
| 115 | + r = client.get("/v1/fundamentals/AAPL/statements?statement=equity") | |
| 116 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 117 | + r = client.get("/v1/fundamentals/AAPL/statements?as_of=yesterday") | |
| 118 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 119 | + r = client.get("/v1/fundamentals/AAPL/statements?limit=0") | |
| 120 | + assert r.status_code == 422 and r.json()["error"]["code"] == "VALIDATION_ERROR" | |
| 121 | + | |
| 122 | + | |
| 123 | +def test_facts_standardized_and_raw(client): | |
| 124 | + r = client.get("/v1/fundamentals/AAPL/facts/revenue?from=2024-03-30&to=2024-03-30") | |
| 125 | + assert r.status_code == 200 | |
| 126 | + rows = _rows(r.json()) | |
| 127 | + q2 = next(x for x in rows if x["fiscal_quarter"] == 2) | |
| 128 | + assert q2["value"] == 90_753_000_000 and q2["coverage"]["tag"].endswith("RevenueFromContractWithCustomerExcludingAssessedTax") | |
| 129 | + assert r.json()["meta"]["kind"] == "standardized" and r.json()["meta"]["tags"][0] == "us-gaap:Revenues" | |
| 130 | + r = client.get("/v1/fundamentals/AAPL/facts/us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax?from=2024-03-30&to=2024-03-30") | |
| 131 | + assert r.status_code == 200 | |
| 132 | + raw = _rows(r.json()) | |
| 133 | + assert any(x["val"] == 90_753_000_000 and x["accn"] == "0000320193-24-000069" for x in raw) | |
| 134 | + assert any(x["frame"] == "CY2024Q1" for x in raw) and r.json()["meta"]["kind"] == "xbrl_fact" | |
| 135 | + r = client.get("/v1/fundamentals/AAPL/facts/us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax?as_of=2024-05-02&from=2024-03-30&to=2024-03-30") | |
| 136 | + assert all(x["end"] != "2024-03-30" or x["start"] != "2023-12-31" for x in _rows(r.json())) | |
| 137 | + r = client.get("/v1/fundamentals/AAPL/facts/us-gaap:NoSuchConcept") | |
| 138 | + assert r.status_code == 404 and r.json()["error"]["code"] == "CONCEPT_NOT_FOUND" | |
| 139 | + r = client.get("/v1/fundamentals/AAPL/facts/dei:EntityCommonStockSharesOutstanding?format=csv") | |
| 140 | + assert r.status_code == 200 and "EntityCommonStockSharesOutstanding" in r.text | |
| 141 | + | |
| 142 | + | |
| 143 | +def test_ratios(client): | |
| 144 | + r = client.get("/v1/fundamentals/AAPL/ratios?as_of=2024-05-03") | |
| 145 | + assert r.status_code == 200 | |
| 146 | + d, m = r.json()["data"], r.json()["meta"] | |
| 147 | + assert d["fundamentals_period_end"] == "2024-03-30" and d["price"] is not None and d["price_date"] <= "2024-05-03" | |
| 148 | + assert d["valuation"]["market_cap"] == pytest.approx(d["price"] * 15_334_082_000) | |
| 149 | + assert d["valuation"]["pe"] == pytest.approx(d["price"] / 6.43, rel=1e-3) | |
| 150 | + assert d["valuation"]["forward_pe"] is None and m["reasons"]["forward_pe"] == "no_estimates" | |
| 151 | + assert d["profitability"]["gross_margin"] == pytest.approx(173_966 / 381_623, rel=1e-6) | |
| 152 | + assert set(d) >= {"valuation", "profitability", "liquidity", "solvency", "efficiency", "growth", "per_share", "inputs"} | |
| 153 | + assert m["shares_source"] == "dei:EntityCommonStockSharesOutstanding" and m["price_source"].startswith("stock/1day/") | |
| 154 | + r = client.get("/v1/fundamentals/AAPL/ratios?period=annual") | |
| 155 | + assert r.status_code == 200 and r.json()["meta"]["period"] == "annual" | |
| 156 | + r = client.get("/v1/fundamentals/AAPL/ratios?period=weekly") | |
| 157 | + assert r.status_code == 400 | |
| 158 | + r = client.get("/v1/fundamentals/SHAK/ratios") | |
| 159 | + d = r.json()["data"] | |
| 160 | + assert d["profitability"]["gross_margin"] is None and r.json()["meta"]["reasons"]["gross_margin"].startswith("missing:") | |
| 161 | + | |
| 162 | + | |
| 163 | +def test_ratios_daily_point_in_time(client): | |
| 164 | + r = client.get("/v1/fundamentals/AAPL/ratios/daily?from=2024-04-25&to=2024-05-10&fields=pe,pb,market_cap") | |
| 165 | + assert r.status_code == 200 | |
| 166 | + rows = _rows(r.json()) | |
| 167 | + assert rows and list(rows[0]) == ["date", "close", "fundamentals_as_of", "fundamentals_period_end", "pe", "pb", "market_cap"] | |
| 168 | + before = [x for x in rows if x["date"] < "2024-05-03"] | |
| 169 | + after = [x for x in rows if x["date"] >= "2024-05-03"] | |
| 170 | + assert before and after | |
| 171 | + assert all(x["fundamentals_period_end"] == "2023-12-30" for x in before) # Q1 FY2024 was the latest public data | |
| 172 | + assert all(x["fundamentals_period_end"] == "2024-03-30" for x in after) # the 10-Q filed May 3 applies from May 3 | |
| 173 | + assert all(x["fundamentals_as_of"] <= x["date"] for x in rows) | |
| 174 | + assert r.json()["meta"]["point_in_time"] is True | |
| 175 | + r = client.get("/v1/fundamentals/AAPL/ratios/daily?from=2024-04-25&to=2024-05-10&fields=nope") | |
| 176 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 177 | + r = client.get("/v1/fundamentals/AAPL/ratios/daily?from=2024-04-25&to=2024-05-10&format=parquet") | |
| 178 | + assert r.status_code == 200 and len(pd.read_parquet(io.BytesIO(r.content))) == int(r.headers["X-Row-Count"]) | |
| 179 | + | |
| 180 | + | |
| 181 | +def test_screener(client): | |
| 182 | + r = client.get("/v1/fundamentals/screener?filters=market_cap>1b&sort=market_cap:desc") | |
| 183 | + assert r.status_code == 200 | |
| 184 | + body = r.json() | |
| 185 | + tickers = [x["ticker"] for x in body["data"]] | |
| 186 | + assert tickers[:2] == ["AAPL", "MSFT"] and body["meta"]["total"] >= 2 | |
| 187 | + r = client.get("/v1/fundamentals/screener?filters=ticker=SHAK&columns=ticker,revenue,gross_margin,net_margin") | |
| 188 | + row = r.json()["data"][0] | |
| 189 | + assert row["ticker"] == "SHAK" and row["gross_margin"] is None and row["net_margin"] is not None | |
| 190 | + r = client.get("/v1/fundamentals/screener?filters=revenue=1..2,pe>0") | |
| 191 | + assert r.status_code == 200 and r.json()["meta"]["count"] == 0 | |
| 192 | + r = client.get("/v1/fundamentals/screener?filters=roe>10000%25") | |
| 193 | + assert r.status_code == 200 and r.json()["meta"]["count"] == 0 | |
| 194 | + r = client.get("/v1/fundamentals/screener?filters=pe<<15") | |
| 195 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_FILTER" | |
| 196 | + r = client.get("/v1/fundamentals/screener?filters=unknown>1") | |
| 197 | + assert r.status_code == 400 and "numeric_fields" in r.json()["error"]["details"] | |
| 198 | + r = client.get("/v1/fundamentals/screener?limit=1&format=csv") | |
| 199 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == "1" | |
| 200 | + r = client.get("/v1/fundamentals/screener?limit=1") | |
| 201 | + nxt = r.json()["meta"]["next_cursor"] | |
| 202 | + assert nxt and client.get(f"/v1/fundamentals/screener?limit=1&cursor={nxt}").json()["data"][0]["ticker"] != r.json()["data"][0]["ticker"] | |
| 203 | + | |
| 204 | + | |
| 205 | +def test_frames(client): | |
| 206 | + r = client.get("/v1/fundamentals/frames/revenue?calendar_quarter=2024Q1") | |
| 207 | + assert r.status_code == 200 | |
| 208 | + rows = _rows(r.json()) | |
| 209 | + by = {x["ticker"]: x for x in rows} | |
| 210 | + assert by["AAPL"]["value"] == 90_753_000_000 and by["AAPL"]["fiscal_quarter"] == 2 # Apple's fiscal Q2 | |
| 211 | + assert by["MSFT"]["fiscal_quarter"] == 3 and by["SHAK"]["fiscal_quarter"] == 1 | |
| 212 | + assert [x["value"] for x in rows] == sorted([x["value"] for x in rows], reverse=True) | |
| 213 | + r = client.get("/v1/fundamentals/frames/revenue?fiscal_year=2024&fiscal_quarter=0&format=csv") | |
| 214 | + assert r.status_code == 200 and "AAPL" in r.text | |
| 215 | + r = client.get("/v1/fundamentals/frames/revenue?calendar_quarter=2024Q1&as_of=2024-05-02") | |
| 216 | + assert "AAPL" not in {x["ticker"] for x in _rows(r.json())} | |
| 217 | + r = client.get("/v1/fundamentals/frames/nope?calendar_quarter=2024Q1") | |
| 218 | + assert r.status_code == 404 and r.json()["error"]["code"] == "CONCEPT_NOT_FOUND" | |
| 219 | + r = client.get("/v1/fundamentals/frames/revenue") | |
| 220 | + assert r.status_code == 400 | |
| 221 | + r = client.get("/v1/fundamentals/frames/revenue?calendar_quarter=2024-Q1x") | |
| 222 | + assert r.status_code == 400 | |
| 223 | + | |
| 224 | + | |
| 225 | +def test_filings(client): | |
| 226 | + r = client.get("/v1/fundamentals/AAPL/filings?form=10-Q&from=2024-01-01&to=2024-12-31") | |
| 227 | + assert r.status_code == 200 | |
| 228 | + rows = _rows(r.json()) | |
| 229 | + assert rows and all(x["form"] == "10-Q" for x in rows) | |
| 230 | + q2 = next(x for x in rows if x["accn"] == "0000320193-24-000069") | |
| 231 | + assert q2["filed_date"] == "2024-05-03" and q2["period_of_report"] == "2024-03-30" and q2["is_xbrl"] is True | |
| 232 | + assert q2["primary_doc_url"] == "https://www.sec.gov/Archives/edgar/data/320193/000032019324000069/aapl-20240330.htm" | |
| 233 | + assert q2["index_url"].endswith("/0000320193-24-000069-index.htm") | |
| 234 | + r = client.get("/v1/fundamentals/AAPL/filings?format=parquet&limit=3") | |
| 235 | + assert r.status_code == 200 and r.headers["X-Row-Count"] == "3" | |
| 236 | + | |
| 237 | + | |
| 238 | +def test_coverage_and_custom_extensions(client): | |
| 239 | + r = client.get("/v1/fundamentals/SHAK/coverage") | |
| 240 | + assert r.status_code == 200 | |
| 241 | + d = r.json()["data"] | |
| 242 | + assert d["cik"] == 1620533 and d["quarters"] > 8 and d["completeness"] is not None | |
| 243 | + assert d["missing_accounts"]["cost_of_revenue"]["reason"] == "no_mapped_tag" | |
| 244 | + ext = {e["tag"] for e in d["custom_extensions"]} | |
| 245 | + assert "shak:OperatingMaterialsExpense" in ext | |
| 246 | + hint = next(e for e in d["custom_extensions"] if e["tag"] == "shak:OperatingMaterialsExpense")["hint_account"] | |
| 247 | + assert hint == "cost_of_revenue" | |
| 248 | + r = client.get("/v1/fundamentals/AAPL/coverage") | |
| 249 | + d = r.json()["data"] | |
| 250 | + assert d["fiscal_year_end"] and d["completeness_by_statement"]["income"] > 80 and d["derived_quarters"] > 0 | |
| 251 | + assert d["gaps"] == [] | |
| 252 | + | |
| 253 | + | |
| 254 | +def test_mapping_and_health(client): | |
| 255 | + r = client.get("/v1/fundamentals/_mapping") | |
| 256 | + assert r.status_code == 200 | |
| 257 | + rev = next(a for a in r.json()["data"] if a["account"] == "revenue") | |
| 258 | + assert rev["tags"][0]["tag"] == "Revenues" and rev["tags"][1]["priority"] == 2 | |
| 259 | + r = client.get("/v1/fundamentals/_health") | |
| 260 | + d = r.json()["data"] | |
| 261 | + assert d["companies"] == 4 and d["statement_versions"] > 200 and d["screener_rows"] == 3 | |
| 262 | + assert d["jobs"]["backfill"]["companies_done"] == 3 and d["jobs"]["backfill"]["mapping_failure_rate"] is not None | |
| 263 | + | |
| 264 | + | |
| 265 | +def test_openapi_declares_fundamentals(client): | |
| 266 | + spec = client.get("/openapi.json").json() | |
| 267 | + op = spec["paths"]["/v1/fundamentals/{ticker}/statements"]["get"] | |
| 268 | + assert op["tags"] == ["fundamentals"] and "404" in op["responses"] and "FUNDAMENTALS_NOT_AVAILABLE" in op["responses"]["404"]["description"] | |
| 269 | + assert "/v1/fundamentals/screener" in spec["paths"] and "/v1/bulk/fundamentals/{year}.parquet" in spec["paths"] | |
| 270 | + assert "/v1/fundamentals/_health" not in spec["paths"] | |
added
tests/test_fundamentals_unit.py
+349 −0
@@ -0,0 +1,349 @@ | ||
| 1 | +"""Unit tests of the fundamentals engine: mapping, fiscal calendar, resolution, derivation, TTM, versioning, ratios.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from datetime import date | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pandas as pd | |
| 8 | +import pytest | |
| 9 | + | |
| 10 | +from tests.conftest import PROTO_CIKS, load_gz | |
| 11 | + | |
| 12 | +ROOT = Path(__file__).resolve().parents[1] | |
| 13 | + | |
| 14 | + | |
| 15 | +def _mods(): | |
| 16 | + from fundamentals import mapping as M | |
| 17 | + from fundamentals import normalize as N | |
| 18 | + from fundamentals import ratios as R | |
| 19 | + from fundamentals import screener as SC | |
| 20 | + return M, N, R, SC | |
| 21 | + | |
| 22 | + | |
| 23 | +def _fact(tag, val, start, end, accn, filed, *, fy=2024, fp="Q1", form="10-Q", unit="USD", taxonomy="us-gaap", frame=None): | |
| 24 | + return dict(cik=1, taxonomy=taxonomy, tag=tag, unit=unit, fy=fy, fp=fp, form=form, start=start, end=end, val=val, | |
| 25 | + accn=accn, filed=filed, frame=frame) | |
| 26 | + | |
| 27 | + | |
| 28 | +def _frame(rows): | |
| 29 | + _, N, _, _ = _mods() | |
| 30 | + df = pd.DataFrame(rows, columns=N.FACT_COLUMNS) | |
| 31 | + for c in ("start", "end", "filed"): | |
| 32 | + df[c] = pd.to_datetime(df[c]).dt.date | |
| 33 | + df["fy"] = df["fy"].astype("Int64") | |
| 34 | + return df | |
| 35 | + | |
| 36 | + | |
| 37 | +# ------------------------------------------------------------------------------------------- mapping | |
| 38 | +def test_mapping_is_prioritized_and_unique(app): | |
| 39 | + M, _, _, _ = _mods() | |
| 40 | + rows = M.mapping_rows() | |
| 41 | + assert len(rows) > 120 | |
| 42 | + for a in M.ACCOUNTS: | |
| 43 | + tags = [(t.taxonomy, t.tag) for t in a.tags] | |
| 44 | + assert len(tags) == len(set(tags)), a.name | |
| 45 | + if not a.computed: | |
| 46 | + assert tags, a.name | |
| 47 | + else: | |
| 48 | + assert a.formula | |
| 49 | + # the spec's chart of accounts, exactly | |
| 50 | + assert [a.name for a in M.accounts_for(M.INCOME) if not a.auxiliary] == [ | |
| 51 | + "revenue", "cost_of_revenue", "gross_profit", "rnd_expense", "sga_expense", "operating_income", "interest_expense", | |
| 52 | + "pretax_income", "income_tax", "net_income", "eps_basic", "eps_diluted", "shares_basic", "shares_diluted", "ebitda", | |
| 53 | + "dividends_paid"] | |
| 54 | + assert [a.name for a in M.accounts_for(M.BALANCE) if not a.auxiliary] == [ | |
| 55 | + "cash_and_equivalents", "short_term_investments", "receivables", "inventory", "total_current_assets", "ppe_net", | |
| 56 | + "goodwill", "intangibles", "total_assets", "accounts_payable", "short_term_debt", "total_current_liabilities", | |
| 57 | + "long_term_debt", "total_liabilities", "retained_earnings", "total_equity", "total_debt", "net_debt", "working_capital"] | |
| 58 | + assert [a.name for a in M.accounts_for(M.CASHFLOW) if not a.auxiliary] == [ | |
| 59 | + "operating_cash_flow", "capex", "free_cash_flow", "acquisitions", "investing_cash_flow", "debt_issued", "debt_repaid", | |
| 60 | + "buybacks", "dividends", "financing_cash_flow", "net_change_in_cash", "stock_based_compensation"] | |
| 61 | + assert M.ACCOUNT_BY_NAME["revenue"].tags[0].tag == "Revenues" | |
| 62 | + assert M.ACCOUNT_BY_NAME["revenue"].tags[1].tag == "RevenueFromContractWithCustomerExcludingAssessedTax" | |
| 63 | + assert M.is_mapped("us-gaap", "Assets") and not M.is_mapped("us-gaap", "OperatingExpenses") | |
| 64 | + | |
| 65 | + | |
| 66 | +def test_mapping_seeded_in_db(fundamentals_data): | |
| 67 | + from sqlalchemy import func, select | |
| 68 | + | |
| 69 | + from core.db import session | |
| 70 | + from fundamentals import mapping as M | |
| 71 | + from fundamentals.models import FundMapping | |
| 72 | + with session() as s: | |
| 73 | + n = s.scalar(select(func.count()).select_from(FundMapping).where(FundMapping.version == M.MAPPING_VERSION)) | |
| 74 | + assert n == len(M.mapping_rows()) | |
| 75 | + | |
| 76 | + | |
| 77 | +# ------------------------------------------------------------------------------------ fiscal calendar | |
| 78 | +def test_fiscal_calendar_apple_and_microsoft(app): | |
| 79 | + _, N, _, _ = _mods() | |
| 80 | + apple = N.FiscalCalendar("0928", {2023: date(2023, 9, 30), 2024: date(2024, 9, 28)}) | |
| 81 | + assert apple.locate(date(2024, 3, 30)) == (2024, 2) # Apple Q2 FY2024 | |
| 82 | + assert apple.locate(date(2023, 12, 30)) == (2024, 1) | |
| 83 | + assert apple.locate(date(2024, 6, 29)) == (2024, 3) | |
| 84 | + assert apple.locate(date(2024, 9, 28)) == (2024, 4) | |
| 85 | + assert apple.locate(date(2025, 3, 29)) == (2025, 2) # extrapolated from MMDD with ±7-day snap | |
| 86 | + assert apple.locate(date(2025, 9, 27)) == (2025, 4) | |
| 87 | + msft = N.FiscalCalendar("0630", {2024: date(2024, 6, 30)}) | |
| 88 | + assert msft.locate(date(2024, 12, 31)) == (2025, 2) | |
| 89 | + assert msft.locate(date(2025, 6, 30)) == (2025, 4) | |
| 90 | + assert msft.locate(date(2024, 9, 30)) == (2025, 1) | |
| 91 | + # 52/53-week retailer ending late December | |
| 92 | + shak = N.FiscalCalendar("1231", {2023: date(2023, 12, 27), 2024: date(2024, 12, 25)}) | |
| 93 | + assert shak.locate(date(2024, 3, 27)) == (2024, 1) | |
| 94 | + assert shak.locate(date(2024, 12, 25)) == (2024, 4) | |
| 95 | + | |
| 96 | + | |
| 97 | +def test_calendar_quarter_and_span(app): | |
| 98 | + _, N, _, _ = _mods() | |
| 99 | + assert N.calendar_quarter(date(2024, 3, 30)) == "2024Q1" | |
| 100 | + assert N.calendar_quarter(date(2023, 12, 30)) == "2023Q4" | |
| 101 | + assert N.calendar_quarter(date(2025, 1, 3)) == "2024Q4" # 53-week year ending in early January | |
| 102 | + assert N.calendar_quarter(date(2024, 6, 29)) == "2024Q2" | |
| 103 | + assert N.span_type(date(2023, 12, 31), date(2024, 3, 30)) == N.SPAN_Q | |
| 104 | + assert N.span_type(date(2023, 10, 1), date(2024, 3, 30)) == N.SPAN_H | |
| 105 | + assert N.span_type(date(2023, 10, 1), date(2024, 6, 29)) == N.SPAN_9M | |
| 106 | + assert N.span_type(date(2023, 10, 1), date(2024, 9, 28)) == N.SPAN_FY | |
| 107 | + assert N.span_type(None, date(2024, 9, 28)) is None | |
| 108 | + assert N.span_type(date(2024, 8, 1), date(2024, 9, 28)) is None # odd stub period | |
| 109 | + | |
| 110 | + | |
| 111 | +# ---------------------------------------------------------------------------------------- resolution | |
| 112 | +def _one_filing(rows, fye="1231", fy_ends=None): | |
| 113 | + _, N, _, _ = _mods() | |
| 114 | + facts = _frame(rows) | |
| 115 | + filings = {"A1": N.Filing("A1", "10-Q", date(2024, 5, 1), date(2024, 3, 31))} | |
| 116 | + cal = N.FiscalCalendar(fye, fy_ends or {2023: date(2023, 12, 31)}) | |
| 117 | + return N.resolve_filing(facts, cal, filings["A1"]) | |
| 118 | + | |
| 119 | + | |
| 120 | +def test_priority_and_fallback(app): | |
| 121 | + periods, _, _ = _one_filing([ | |
| 122 | + _fact("RevenueFromContractWithCustomerExcludingAssessedTax", 100, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 123 | + _fact("SalesRevenueNet", 90, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 124 | + ]) | |
| 125 | + inc = periods[(2024, 1, "Q")]["income"] | |
| 126 | + assert inc.values["revenue"] == 100 and inc.coverage["revenue"]["priority"] == 2 | |
| 127 | + periods, _, _ = _one_filing([ | |
| 128 | + _fact("Revenues", 120, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 129 | + _fact("RevenueFromContractWithCustomerExcludingAssessedTax", 100, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 130 | + ]) | |
| 131 | + inc = periods[(2024, 1, "Q")]["income"] | |
| 132 | + assert inc.values["revenue"] == 120 and inc.coverage["revenue"]["tag"] == "us-gaap:Revenues" | |
| 133 | + | |
| 134 | + | |
| 135 | +def test_component_sum_and_unit_normalisation(app): | |
| 136 | + periods, _, _ = _one_filing([ | |
| 137 | + _fact("GeneralAndAdministrativeExpense", 30, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 138 | + _fact("SellingAndMarketingExpense", 20, "2024-01-01", "2024-03-31", "A1", "2024-05-01"), | |
| 139 | + _fact("EarningsPerShareDiluted", 1.5, "2024-01-01", "2024-03-31", "A1", "2024-05-01", unit="USD/shares"), | |
| 140 | + _fact("WeightedAverageNumberOfDilutedSharesOutstanding", 1_000, "2024-01-01", "2024-03-31", "A1", "2024-05-01", unit="shares"), | |
| 141 | + _fact("Assets", 5_000, None, "2024-03-31", "A1", "2024-05-01", unit="CAD"), | |
| 142 | + _fact("Assets", 999, None, "2024-03-31", "A1", "2024-05-01", unit="pure"), # wrong unit: ignored | |
| 143 | + _fact("EarningsPerShareDiluted", 7, "2024-01-01", "2024-03-31", "A1", "2024-05-01", unit="USD"), # wrong unit: ignored | |
| 144 | + ]) | |
| 145 | + inc = periods[(2024, 1, "Q")]["income"] | |
| 146 | + assert inc.values["sga_expense"] == 50 and inc.coverage["sga_expense"]["components"] is True | |
| 147 | + assert inc.values["eps_diluted"] == 1.5 and inc.values["shares_diluted"] == 1_000 | |
| 148 | + bal = periods[(2024, 1, "I")]["balance"] | |
| 149 | + assert bal.values["total_assets"] == 5_000 and bal.currency == "CAD" and bal.coverage["total_assets"]["currency"] == "CAD" | |
| 150 | + | |
| 151 | + | |
| 152 | +# -------------------------------------------------------------------------------- derivation / versions | |
| 153 | +def _company(rows, fye="1231"): | |
| 154 | + _, N, _, _ = _mods() | |
| 155 | + facts = _frame(rows) | |
| 156 | + return N.normalize_company(1, "TEST", facts, {}, fye) | |
| 157 | + | |
| 158 | + | |
| 159 | +def test_q4_derivation_requires_three_quarters_same_year(app): | |
| 160 | + def q(tag, val, s, e, accn, filed, fp, fy=2023, form="10-Q"): | |
| 161 | + return _fact(tag, val, s, e, accn, filed, fy=fy, fp=fp, form=form) | |
| 162 | + rows = [q("Revenues", 10, "2023-01-01", "2023-03-31", "Q1", "2023-05-01", "Q1"), | |
| 163 | + q("Revenues", 20, "2023-04-01", "2023-06-30", "Q2", "2023-08-01", "Q2"), | |
| 164 | + q("Revenues", 30, "2023-07-01", "2023-09-30", "Q3", "2023-11-01", "Q3"), | |
| 165 | + q("Revenues", 100, "2023-01-01", "2023-12-31", "K", "2024-02-15", "FY", form="10-K"), | |
| 166 | + q("WeightedAverageNumberOfDilutedSharesOutstanding", 100, "2023-01-01", "2023-03-31", "Q1", "2023-05-01", "Q1"), | |
| 167 | + q("WeightedAverageNumberOfDilutedSharesOutstanding", 100, "2023-04-01", "2023-06-30", "Q2", "2023-08-01", "Q2"), | |
| 168 | + q("WeightedAverageNumberOfDilutedSharesOutstanding", 100, "2023-07-01", "2023-09-30", "Q3", "2023-11-01", "Q3"), | |
| 169 | + q("WeightedAverageNumberOfDilutedSharesOutstanding", 95, "2023-01-01", "2023-12-31", "K", "2024-02-15", "FY", form="10-K")] | |
| 170 | + for r in rows: | |
| 171 | + r["unit"] = "shares" if "Shares" in r["tag"] else "USD" | |
| 172 | + res = _company(rows) | |
| 173 | + q4 = [r for r in res.rows if r["statement"] == "income" and r["fiscal_quarter"] == 4] | |
| 174 | + assert len(q4) == 1 and q4[0]["derived"] is True | |
| 175 | + assert q4[0]["revenue"] == 40 and q4[0]["coverage"]["revenue"]["derived"] == "FY-(Q1+Q2+Q3)" | |
| 176 | + assert q4[0]["shares_diluted"] == 4 * 95 - 300 and q4[0]["coverage"]["shares_diluted"]["approx"] is True | |
| 177 | + assert q4[0]["form"] == "10-K" and str(q4[0]["filed_date"]) == "2024-02-15" and str(q4[0]["period_end"]) == "2023-12-31" | |
| 178 | + # drop Q2 → Q4 cannot be derived, no invented value | |
| 179 | + res2 = _company([r for r in rows if r["accn"] != "Q2"]) | |
| 180 | + assert not [r for r in res2.rows if r["statement"] == "income" and r["fiscal_quarter"] == 4] | |
| 181 | + | |
| 182 | + | |
| 183 | +def test_restatement_versioning_and_as_of(app): | |
| 184 | + _, N, _, _ = _mods() | |
| 185 | + rows = [_fact("Revenues", 100, "2023-01-01", "2023-03-31", "Q1", "2023-05-01", fy=2023, fp="Q1"), | |
| 186 | + _fact("Revenues", 110, "2023-01-01", "2023-03-31", "Q1A", "2023-09-01", fy=2023, fp="Q1", form="10-Q/A"), | |
| 187 | + _fact("Revenues", 110, "2023-01-01", "2023-03-31", "Q1N", "2024-05-01", fy=2024, fp="Q1")] # comparative, unchanged | |
| 188 | + res = _company(rows) | |
| 189 | + versions = sorted([r for r in res.rows if r["statement"] == "income"], key=lambda r: r["filed_date"]) | |
| 190 | + assert [(v["revenue"], v["restated"], v["form"]) for v in versions] == [(100, False, "10-Q"), (110, True, "10-Q/A")] | |
| 191 | + assert N.select_as_of(res.rows, date(2023, 6, 1))[0]["revenue"] == 100 | |
| 192 | + assert N.select_as_of(res.rows, date(2023, 9, 1))[0]["revenue"] == 110 | |
| 193 | + assert N.select_as_of(res.rows, None)[0]["revenue"] == 110 | |
| 194 | + assert N.select_as_of(res.rows, date(2023, 4, 30)) == [] # nothing was public yet | |
| 195 | + | |
| 196 | + | |
| 197 | +def test_lower_priority_comparative_does_not_restate(app): | |
| 198 | + """A later filing re-reporting a period with a worse concept (cash incl. restricted) must not override.""" | |
| 199 | + rows = [_fact("CashAndCashEquivalentsAtCarryingValue", 100, None, "2024-03-30", "A", "2024-05-03", fy=2024, fp="Q2"), | |
| 200 | + _fact("CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents", 105, None, "2024-03-30", "B", "2025-05-02", fy=2025, fp="Q2")] | |
| 201 | + res = _company(rows, fye="0930") | |
| 202 | + bal = [r for r in res.rows if r["statement"] == "balance" and r["fiscal_quarter"] != 0] | |
| 203 | + assert len(bal) == 1 and bal[0]["cash_and_equivalents"] == 100 and bal[0]["restated"] is False | |
| 204 | + | |
| 205 | + | |
| 206 | +# ------------------------------------------------------------------------------------ real fixtures | |
| 207 | +@pytest.fixture(scope="module") | |
| 208 | +def apple_rows(app): | |
| 209 | + _, N, _, _ = _mods() | |
| 210 | + cf = load_gz(f"companyfacts_CIK{PROTO_CIKS['AAPL']:010d}.json.gz") | |
| 211 | + sub = load_gz(f"submissions_CIK{PROTO_CIKS['AAPL']:010d}.json.gz") | |
| 212 | + res = N.normalize_company(320193, "AAPL", N.facts_frame(cf), N.filings_from_submissions(sub), sub["fiscalYearEnd"]) | |
| 213 | + assert res.stats["fiscal_mismatches"] == [] | |
| 214 | + return res.rows | |
| 215 | + | |
| 216 | + | |
| 217 | +def _row(rows, statement, fy, fq): | |
| 218 | + return next(r for r in rows if r["statement"] == statement and r["fiscal_year"] == fy and r["fiscal_quarter"] == fq) | |
| 219 | + | |
| 220 | + | |
| 221 | +def test_apple_q2_fy2024_real_numbers(apple_rows): | |
| 222 | + """Apple 10-Q filed 2024-05-03 (quarter ended 2024-03-30): the reference numbers of the spec.""" | |
| 223 | + _, N, _, _ = _mods() | |
| 224 | + latest = N.select_as_of(apple_rows, None) | |
| 225 | + inc = _row(latest, "income", 2024, 2) | |
| 226 | + assert str(inc["period_end"]) == "2024-03-30" and inc["calendar_quarter"] == "2024Q1" | |
| 227 | + assert inc["revenue"] == 90_753_000_000 and inc["net_income"] == 23_636_000_000 and inc["eps_diluted"] == 1.53 | |
| 228 | + assert inc["derived"] is False and inc["coverage"]["revenue"]["tag"] == "us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax" | |
| 229 | + bal = _row(latest, "balance", 2024, 2) | |
| 230 | + assert bal["total_assets"] == 337_411_000_000 | |
| 231 | + cf = _row(latest, "cashflow", 2024, 2) | |
| 232 | + assert cf["operating_cash_flow"] == 22_690_000_000 # de-cumulated: YTD6 (62,585) − Q1 (39,895) | |
| 233 | + assert cf["derived"] is True and cf["coverage"]["operating_cash_flow"]["derived"] == "YTD6-Q1" | |
| 234 | + q4 = _row(latest, "income", 2024, 4) | |
| 235 | + # Apple reports 9-month YTD income facts, so Q4 = FY − YTD9 (identical to FY − (Q1+Q2+Q3), fewer inputs) | |
| 236 | + assert q4["derived"] is True and q4["revenue"] == 94_930_000_000 and q4["coverage"]["revenue"]["derived"] == "FY-YTD9" | |
| 237 | + fy = _row(latest, "income", 2024, 0) | |
| 238 | + assert fy["revenue"] == 391_035_000_000 and fy["eps_diluted"] == 6.08 | |
| 239 | + # nulls are explained, never guessed | |
| 240 | + assert inc["interest_expense"] is None and inc["coverage"]["interest_expense"]["reason"] == "no_mapped_tag" | |
| 241 | + | |
| 242 | + | |
| 243 | +def test_apple_ttm_and_point_in_time(apple_rows): | |
| 244 | + _, N, _, _ = _mods() | |
| 245 | + latest = N.select_as_of(apple_rows, None) | |
| 246 | + t = N.ttm(latest, "income") | |
| 247 | + w = next(x for x in t if x["fiscal_year"] == 2024 and x["fiscal_quarter"] == 2) | |
| 248 | + assert w["revenue"] == 119_575_000_000 + 90_753_000_000 + 89_498_000_000 + 81_797_000_000 == 381_623_000_000 | |
| 249 | + assert w["ttm"] is True and w["shares_diluted"] == _row(latest, "income", 2024, 2)["shares_diluted"] | |
| 250 | + assert w["coverage"]["eps_diluted"]["approx"] | |
| 251 | + bt = N.ttm(latest, "balance") | |
| 252 | + assert next(x for x in bt if x["fiscal_year"] == 2024 and x["fiscal_quarter"] == 2)["total_assets"] == 337_411_000_000 | |
| 253 | + # anti look-ahead | |
| 254 | + assert not [r for r in N.select_as_of(apple_rows, date(2024, 5, 2)) if r["statement"] == "income" and (r["fiscal_year"], r["fiscal_quarter"]) == (2024, 2)] | |
| 255 | + assert [r for r in N.select_as_of(apple_rows, date(2024, 5, 3)) if r["statement"] == "income" and (r["fiscal_year"], r["fiscal_quarter"]) == (2024, 2)] | |
| 256 | + | |
| 257 | + | |
| 258 | +def test_ratio_formulas_on_apple_q2_fy2024(apple_rows): | |
| 259 | + """Formulas checked against the values of the recorded fixture (TTM to 2024-03-30, close 183.38 on 2024-05-03).""" | |
| 260 | + _, N, R, _ = _mods() | |
| 261 | + from fundamentals.service import ratio_inputs | |
| 262 | + rows = N.select_as_of(apple_rows, date(2024, 5, 3)) | |
| 263 | + price = 183.38 | |
| 264 | + f, info = ratio_inputs(rows, price=price) | |
| 265 | + assert str(info["period_end"]) == "2024-03-30" and info["shares_source"] == "dei:EntityCommonStockSharesOutstanding" | |
| 266 | + values, reasons = R.compute_all(f) | |
| 267 | + assert f["revenue"] == 381_623_000_000 and f["net_income"] == 100_389_000_000 | |
| 268 | + assert f["shares_outstanding"] == 15_334_082_000 # cover page of the 10-Q (2024-04-19) | |
| 269 | + assert values["market_cap"] == pytest.approx(price * 15_334_082_000) | |
| 270 | + assert values["pe"] == pytest.approx(price / f["eps_diluted"]) and f["eps_diluted"] == pytest.approx(6.43) | |
| 271 | + assert values["pb"] == pytest.approx(values["market_cap"] / 74_194_000_000) | |
| 272 | + assert values["gross_margin"] == pytest.approx(173_966 / 381_623, rel=1e-6) | |
| 273 | + assert values["net_margin"] == pytest.approx(100_389 / 381_623, rel=1e-6) | |
| 274 | + assert values["roe"] == pytest.approx(100_389 / 74_194, rel=1e-6) | |
| 275 | + assert values["enterprise_value"] == pytest.approx(values["market_cap"] + 104_590e6 - 32_695e6 - 34_455e6) | |
| 276 | + assert values["ev_ebitda"] == pytest.approx(values["enterprise_value"] / f["ebitda"]) | |
| 277 | + assert values["fcf_yield"] == pytest.approx(f["free_cash_flow"] / values["market_cap"]) | |
| 278 | + assert values["current_ratio"] == pytest.approx(128_416 / 123_822, rel=1e-6) | |
| 279 | + assert values["debt_to_equity"] == pytest.approx(104_590 / 74_194, rel=1e-6) | |
| 280 | + assert values["forward_pe"] is None and reasons["forward_pe"] == "no_estimates" | |
| 281 | + assert values["interest_coverage"] is None and reasons["interest_coverage"].startswith("missing:") | |
| 282 | + assert values["revenue_growth_yoy"] == pytest.approx(381_623 / 385_095 - 1, rel=1e-6) | |
| 283 | + assert values["book_value_ps"] == pytest.approx(74_194_000_000 / 15_334_082_000) | |
| 284 | + | |
| 285 | + | |
| 286 | +def test_ratio_docs_are_in_sync(app): | |
| 287 | + _, _, R, _ = _mods() | |
| 288 | + assert (ROOT / "docs" / "fundamentals-ratios.md").read_text(encoding="utf-8").strip() == R.render_docs().strip() | |
| 289 | + assert len(R.RATIOS) == len(set(r.name for r in R.RATIOS)) >= 45 | |
| 290 | + | |
| 291 | + | |
| 292 | +def test_null_ratios_never_invented(app): | |
| 293 | + _, _, R, _ = _mods() | |
| 294 | + values, reasons = R.compute_all({"price": 10.0, "eps_diluted": -1.0, "total_equity": 0.0, "revenue": None}) | |
| 295 | + assert values["pe"] is None and reasons["pe"] == "denominator_not_positive" | |
| 296 | + assert values["pb"] is None and values["ps"] is None and reasons["ps"].startswith("missing:") | |
| 297 | + | |
| 298 | + | |
| 299 | +# -------------------------------------------------------------------------------------- filter grammar | |
| 300 | +def test_filter_grammar(app): | |
| 301 | + from core.errors import ApiError | |
| 302 | + _, _, _, SC = _mods() | |
| 303 | + f = SC.parse_filters("pe<15, roe>=15%,market_cap>1.5b,ev_ebitda=5..12,exchange=Nasdaq|NYSE,ticker!=AAPL") | |
| 304 | + assert [(x.field, x.op, x.value) for x in f] == [("pe", "<", 15.0), ("roe", ">=", 0.15), ("market_cap", ">", 1.5e9), | |
| 305 | + ("ev_ebitda", "range", (5.0, 12.0)), ("exchange", "=", ["NASDAQ", "NYSE"]), | |
| 306 | + ("ticker", "!=", ["AAPL"])] | |
| 307 | + assert SC.parse_sort("fcf_yield:desc") == ("fcf_yield", True) and SC.parse_sort(None) == ("market_cap", True) | |
| 308 | + for bad in ("pe<<15", "nope>1", "pe>", "pe=abc", "pe=20..10", "exchange>1", "pe~1"): | |
| 309 | + with pytest.raises(ApiError) as e: | |
| 310 | + SC.parse_filters(bad) | |
| 311 | + assert e.value.code == "INVALID_FILTER" | |
| 312 | + with pytest.raises(ApiError): | |
| 313 | + SC.parse_sort("pe:sideways") | |
| 314 | + | |
| 315 | + | |
| 316 | +# ------------------------------------------------------------------------------------ edgar client | |
| 317 | +def test_token_bucket_and_backoff(app): | |
| 318 | + import httpx | |
| 319 | + import respx | |
| 320 | + | |
| 321 | + from fundamentals import edgar_client as ec | |
| 322 | + calls = {"n": 0} | |
| 323 | + | |
| 324 | + def handler(request): | |
| 325 | + calls["n"] += 1 | |
| 326 | + if calls["n"] == 1: | |
| 327 | + return httpx.Response(429, headers={"Retry-After": "0"}) | |
| 328 | + return httpx.Response(200, json={"ok": True}) | |
| 329 | + | |
| 330 | + with respx.mock(assert_all_mocked=True) as m: | |
| 331 | + m.get("https://data.sec.gov/x.json").mock(side_effect=handler) | |
| 332 | + c = ec.EdgarClient(rate=10_000, raw_dir=Path("/tmp/hfmd-test-raw")) | |
| 333 | + assert c.get_json("https://data.sec.gov/x.json") == {"ok": True} | |
| 334 | + assert c.stats.retries == 1 and calls["n"] == 2 | |
| 335 | + assert c._http.headers["User-Agent"].startswith("HF Market Data (Simon-Pierre Boucher") | |
| 336 | + b = ec.TokenBucket(rate=1000, capacity=3) | |
| 337 | + for _ in range(10): | |
| 338 | + b.acquire() | |
| 339 | + | |
| 340 | + | |
| 341 | +def test_parse_feeds(app): | |
| 342 | + from fundamentals import edgar_client as ec | |
| 343 | + from fundamentals import ingest | |
| 344 | + atom = (ROOT / "tests" / "fixtures" / "edgar" / "atom_10-Q.xml").read_text() | |
| 345 | + entries = ec.parse_atom(atom) | |
| 346 | + assert len(entries) == 3 and all(e["accn"] and e["cik"] and e["form"] == "10-Q" for e in entries) | |
| 347 | + idx = ingest.parse_master_index((ROOT / "tests" / "fixtures" / "edgar" / "master.idx").read_text()) | |
| 348 | + assert idx and all(set(e) == {"cik", "name", "form", "filed", "accn"} for e in idx) | |
| 349 | + assert ingest.master_index_url(date(2026, 9, 3)).endswith("/2026/QTR3/master.20260903.idx") | |
added
tests/test_stream.py
+153 −0
@@ -0,0 +1,153 @@ | ||
| 1 | +"""WebSocket /v1/stream: auth, subscribe, delivery of published events, resume, heartbeat, connection limit.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import pytest | |
| 5 | +from starlette.websockets import WebSocketDisconnect | |
| 6 | + | |
| 7 | +KEY = "hfmd_live_" + "a" * 32 | |
| 8 | +KEY2 = "hfmd_live_" + "b" * 32 | |
| 9 | + | |
| 10 | + | |
| 11 | +@pytest.fixture(autouse=True) | |
| 12 | +def _reset(app): | |
| 13 | + from stream import broker | |
| 14 | + broker.reset_for_tests() | |
| 15 | + yield | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_keyless_is_refused_with_json_reason(client): | |
| 19 | + with client.websocket_connect("/v1/stream") as ws: | |
| 20 | + msg = ws.receive_json() | |
| 21 | + assert msg["type"] == "error" and msg["code"] == "AUTH_REQUIRED" and msg["docs"].endswith("#auth_required") | |
| 22 | + with pytest.raises(WebSocketDisconnect) as e: | |
| 23 | + ws.receive_json() | |
| 24 | + assert e.value.code == 4001 | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_bad_key_is_refused(client): | |
| 28 | + with client.websocket_connect("/v1/stream", headers={"Authorization": "Bearer not-a-key"}) as ws: | |
| 29 | + assert ws.receive_json()["code"] == "AUTH_REQUIRED" | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_subscribe_receive_and_accounting(client, fundamentals_data): | |
| 33 | + from stream import accounting, broker | |
| 34 | + from stream.auth import authenticate | |
| 35 | + principal = authenticate(KEY) | |
| 36 | + before = accounting.rows_charged(principal) | |
| 37 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: | |
| 38 | + hello = ws.receive_json() | |
| 39 | + assert hello["type"] == "hello" and hello["heartbeat_seconds"] == 20 | |
| 40 | + ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "forms": ["10-Q", "10-K"]}) | |
| 41 | + sub = ws.receive_json() | |
| 42 | + assert sub["type"] == "subscribed" and sub["tickers"] == ["AAPL"] and sub["forms"] == ["10-K", "10-Q"] | |
| 43 | + # events published by the ingestion (Redis) — one matching, two filtered out | |
| 44 | + broker.publish({"type": "filing", "ticker": "MSFT", "form": "10-Q", "accn": "x1"}) | |
| 45 | + broker.publish({"type": "filing", "ticker": "AAPL", "form": "8-K", "accn": "x2"}) | |
| 46 | + seq = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-Q", "accn": "x3", "filed_date": "2024-05-03", | |
| 47 | + "summary": {"revenue": 90_753_000_000}}) | |
| 48 | + ev = ws.receive_json() | |
| 49 | + assert ev["type"] == "filing" and ev["accn"] == "x3" and ev["seq"] == seq and ev["summary"]["revenue"] == 90_753_000_000 | |
| 50 | + ws.send_json({"action": "ping"}) | |
| 51 | + pong = ws.receive_json() | |
| 52 | + assert pong["type"] == "pong" and pong["seq"] == seq | |
| 53 | + assert accounting.rows_charged(principal) == before + 1 | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_real_filing_event_from_ingest(client, fundamentals_data): | |
| 57 | + from datetime import date | |
| 58 | + | |
| 59 | + from fundamentals import ingest | |
| 60 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: | |
| 61 | + ws.receive_json() | |
| 62 | + ws.send_json({"action": "subscribe", "channel": "filings", "tickers": "all"}) | |
| 63 | + ws.receive_json() | |
| 64 | + ev = ingest.publish_filing_event(320193, "AAPL", "10-Q", date(2024, 5, 3), date(2024, 3, 30), "0000320193-24-000069", | |
| 65 | + "aapl-20240330.htm") | |
| 66 | + assert ev is not None | |
| 67 | + got = ws.receive_json() | |
| 68 | + assert got["ticker"] == "AAPL" and got["form"] == "10-Q" and got["filed_date"] == "2024-05-03" | |
| 69 | + assert got["url"].endswith("/000032019324000069/aapl-20240330.htm") | |
| 70 | + s = got["summary"] | |
| 71 | + assert s["revenue"] == 90_753_000_000 and s["net_income"] == 23_636_000_000 and s["eps_diluted"] == 1.53 | |
| 72 | + assert s["total_assets"] == 337_411_000_000 and s["operating_cash_flow"] == 22_690_000_000 | |
| 73 | + assert s["yoy"]["revenue"] == pytest.approx(90_753 / 94_836 - 1, rel=1e-4) | |
| 74 | + | |
| 75 | + | |
| 76 | +def test_resume_token_replays_buffer(client): | |
| 77 | + from stream import broker | |
| 78 | + s1 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r1"}) | |
| 79 | + s2 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r2"}) | |
| 80 | + s3 = broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r3"}) | |
| 81 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: | |
| 82 | + assert ws.receive_json()["seq"] == s3 | |
| 83 | + ws.send_json({"action": "subscribe", "channel": "filings", "tickers": ["AAPL"], "resume_token": s1}) | |
| 84 | + assert ws.receive_json()["resume_from"] == s1 | |
| 85 | + assert [ws.receive_json()["accn"] for _ in range(2)] == ["r2", "r3"] | |
| 86 | + assert s2 < s3 | |
| 87 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: # without resume: only new events | |
| 88 | + ws.receive_json() | |
| 89 | + ws.send_json({"action": "subscribe"}) | |
| 90 | + ws.receive_json() | |
| 91 | + broker.publish({"type": "filing", "ticker": "AAPL", "form": "10-K", "accn": "r4"}) | |
| 92 | + assert ws.receive_json()["accn"] == "r4" | |
| 93 | + | |
| 94 | + | |
| 95 | +def test_protocol_errors(client): | |
| 96 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: | |
| 97 | + ws.receive_json() | |
| 98 | + ws.send_text("not json") | |
| 99 | + assert ws.receive_json()["code"] == "VALIDATION_ERROR" | |
| 100 | + ws.send_json({"action": "subscribe", "channel": "trades"}) | |
| 101 | + assert ws.receive_json()["code"] == "NOT_FOUND" | |
| 102 | + ws.send_json({"action": "subscribe", "tickers": []}) | |
| 103 | + assert ws.receive_json()["code"] == "VALIDATION_ERROR" | |
| 104 | + ws.send_json({"action": "dance"}) | |
| 105 | + assert ws.receive_json()["code"] == "VALIDATION_ERROR" | |
| 106 | + ws.send_json({"action": "subscribe", "resume_token": "abc"}) | |
| 107 | + assert ws.receive_json()["code"] == "VALIDATION_ERROR" | |
| 108 | + | |
| 109 | + | |
| 110 | +def test_heartbeat(client, monkeypatch): | |
| 111 | + from stream import routes | |
| 112 | + monkeypatch.setattr(routes, "HEARTBEAT_SECONDS", 0.3) | |
| 113 | + with client.websocket_connect(f"/v1/stream?api_key={KEY2}") as ws: | |
| 114 | + ws.receive_json() | |
| 115 | + beat = ws.receive_json() | |
| 116 | + assert beat["type"] == "heartbeat" and "ts" in beat and "seq" in beat | |
| 117 | + | |
| 118 | + | |
| 119 | +def test_connection_limit_per_key(client): | |
| 120 | + from contextlib import ExitStack | |
| 121 | + | |
| 122 | + from stream import routes | |
| 123 | + with ExitStack() as stack: | |
| 124 | + for _ in range(routes.MAX_CONNECTIONS_PER_KEY): | |
| 125 | + ws = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={KEY}")) | |
| 126 | + assert ws.receive_json()["type"] == "hello" | |
| 127 | + extra = stack.enter_context(client.websocket_connect(f"/v1/stream?api_key={KEY}")) | |
| 128 | + msg = extra.receive_json() | |
| 129 | + assert msg["type"] == "error" and msg["code"] == "STREAM_CONNECTION_LIMIT" and msg["limit"] == 5 | |
| 130 | + with pytest.raises(WebSocketDisconnect) as e: | |
| 131 | + extra.receive_json() | |
| 132 | + assert e.value.code == 4029 | |
| 133 | + # slots are released on disconnect | |
| 134 | + with client.websocket_connect(f"/v1/stream?api_key={KEY}") as ws: | |
| 135 | + assert ws.receive_json()["type"] == "hello" | |
| 136 | + | |
| 137 | + | |
| 138 | +def test_accounting_hook_interface(app): | |
| 139 | + from stream import accounting | |
| 140 | + calls = [] | |
| 141 | + accounting.set_charger(lambda principal, rows: calls.append((principal, rows))) | |
| 142 | + try: | |
| 143 | + accounting.charge("key:abc", 1) | |
| 144 | + assert calls == [("key:abc", 1)] and accounting.get_charger() is not None | |
| 145 | + finally: | |
| 146 | + accounting.set_charger(None) | |
| 147 | + | |
| 148 | + | |
| 149 | +def test_stream_info(client): | |
| 150 | + r = client.get("/v1/stream/info") | |
| 151 | + assert r.status_code == 200 | |
| 152 | + d = r.json()["data"] | |
| 153 | + assert d["channels"] == ["filings"] and d["close_codes"]["4001"] == "AUTH_REQUIRED" and d["url"].startswith("wss://") | |
| 154 | ||