SPB Git forge

spb/hfmarketdata

Public

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

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

fundamentals: 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>
Simon-Pierre Boucher committed 20 days ago (Sep 5, 2026) parent c82a98f

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 + &lt;b&gt;Filed:&lt;/b&gt; 2026-09-04 &lt;b&gt;AccNo:&lt;/b&gt; 0001628280-26-060770 &lt;b&gt;Size:&lt;/b&gt; 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 + &lt;b&gt;Filed:&lt;/b&gt; 2026-09-04 &lt;b&gt;AccNo:&lt;/b&gt; 0001493152-26-041603 &lt;b&gt;Size:&lt;/b&gt; 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 + &lt;b&gt;Filed:&lt;/b&gt; 2026-09-04 &lt;b&gt;AccNo:&lt;/b&gt; 0001327688-26-000020 &lt;b&gt;Size:&lt;/b&gt; 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