Files
tradingagents/tests/test_sec_edgar.py
Yijia-Xiao 2717033ad8 fix(sec_edgar): serve a statement one row per period (#1517, #1542)
- a row per period with the line items as columns, so each figure sits beside its own date across a filer's whole history
- lines the filer never tagged are named once in the header
2026-10-27 07:59:13 +00:00

358 lines
16 KiB
Python

"""SEC EDGAR fundamentals: statements as they were filed, not as they read today.
Every other fundamentals vendor serves the current value of a past period and
cuts on the fiscal period end, so a run sees figures the company had not yet
filed, and later restatements replace what was actually published. EDGAR carries
the filing date of every fact, so a run can be limited to what was on file by its
own date.
"""
from __future__ import annotations
from unittest import mock
import pytest
from tradingagents.dataflows.errors import NoMarketDataError
from tradingagents.dataflows.vendors import sec_edgar
_REAL_FETCH = sec_edgar._fetch_json
TICKER_MAP = {"0": {"cik_str": 320193, "ticker": "AAPL", "title": "Apple Inc."}}
def _fact(end, val, filed, form="10-K", fp="FY", start=None):
fact = {"end": end, "val": val, "filed": filed, "form": form, "fy": int(end[:4]), "fp": fp}
if start:
fact["start"] = start
return fact
FACTS = {
"cik": 320193,
"entityName": "Apple Inc.",
"facts": {"us-gaap": {
"Assets": {"units": {"USD": [
_fact("2008-09-27", 39_572_000_000, "2008-11-05"),
_fact("2008-09-27", 36_171_000_000, "2010-01-25", form="10-K/A"),
_fact("2022-03-26", 350_662_000_000, "2022-04-29", form="10-Q", fp="Q2"),
_fact("2024-09-28", 364_980_000_000, "2024-11-01"),
]}},
"Liabilities": {"units": {"USD": [_fact("2024-09-28", 308_030_000_000, "2024-11-01")]}},
"EarningsPerShareDiluted": {"units": {"USD/shares": [
_fact("2024-09-28", 6.08, "2024-11-01", start="2023-09-30"),
]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
# One filing reports the quarter and the year to date under one end date.
_fact("2025-12-31", 81_300_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-10-01"),
_fact("2025-12-31", 158_900_000_000, "2026-01-29", form="10-Q", fp="Q2", start="2025-07-01"),
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30"),
]}},
}},
}
@pytest.fixture(autouse=True)
def _no_network_or_cache(tmp_path, monkeypatch):
monkeypatch.setattr(sec_edgar, "get_config", lambda: {"data_cache_dir": str(tmp_path)})
monkeypatch.setattr(sec_edgar, "_fetch_json", lambda url: TICKER_MAP if "company_tickers" in url else FACTS)
@pytest.mark.unit
def test_a_us_filer_resolves_to_its_cik():
assert sec_edgar.cik_for("AAPL") == "0000320193"
assert sec_edgar.cik_for("aapl") == "0000320193"
@pytest.mark.unit
def test_a_non_filer_is_reported_as_such_not_as_missing_data():
with pytest.raises(NoMarketDataError, match="not a US SEC filer"):
sec_edgar.get_balance_sheet("0700.HK", "annual", "2026-01-01")
@pytest.mark.unit
def test_a_restated_figure_reads_as_it_did_at_the_time():
"""The value published then, not the correction filed later."""
as_filed = sec_edgar.get_balance_sheet("AAPL", "annual", "2009-06-30")
restated = sec_edgar.get_balance_sheet("AAPL", "annual", "2011-01-01")
assert "39572" in as_filed and "36171" not in as_filed
assert "36171" in restated
@pytest.mark.unit
def test_a_period_that_ended_but_was_not_filed_yet_is_not_served():
"""The fiscal year ended 2024-09-28; it reached the public on 2024-11-01."""
before = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-10-15")
after = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "2024-09-28" not in before
assert "2024-09-28" in after and "364980" in after
@pytest.mark.unit
def test_the_quarter_is_not_confused_with_the_year_to_date():
"""One filing carries both spans under the same end date (#MSFT-shaped)."""
out = sec_edgar.get_income_statement("AAPL", "quarterly", "2026-06-01")
assert "81300" in out
assert "158900" not in out
@pytest.mark.unit
def test_a_line_the_filer_does_not_tag_is_named_as_such():
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
untagged = [line for line in out.splitlines() if line.startswith("# Not tagged by this filer:")]
assert len(untagged) == 1 and "Stockholders Equity" in untagged[0]
assert "364980" in out # the rest of the statement still returns
@pytest.mark.unit
def test_the_report_states_the_vintage_rule():
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert "filed on or before 2024-11-15" in out
@pytest.mark.unit
def test_a_filer_with_no_usable_facts_reads_differently_from_a_non_filer(monkeypatch):
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else {"facts": {}})
with pytest.raises(NoMarketDataError, match="no us-gaap facts"):
sec_edgar.get_balance_sheet("AAPL", "annual", "2026-01-01")
@pytest.mark.unit
def test_company_facts_are_fetched_once_per_company_not_once_per_date(tmp_path, monkeypatch):
"""A sweep asks for many dates; the filing history is the same file."""
calls = []
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: calls.append(url) or (TICKER_MAP if "company_tickers" in url else FACTS))
for date in ("2024-11-15", "2025-01-15", "2025-06-15"):
sec_edgar.get_balance_sheet("AAPL", "annual", date)
assert len([u for u in calls if "companyfacts" in u]) == 1
@pytest.mark.unit
def test_it_works_unconfigured_and_takes_the_caller_s_own_contact(monkeypatch):
"""SEC returns 403 for a User-Agent with no contact address, so the default
carries one; a caller who sets their own replaces it."""
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
assert "@" in sec_edgar._user_agent()
monkeypatch.setenv("SEC_EDGAR_USER_AGENT", "MyDesk research@example.com")
assert sec_edgar._user_agent() == "MyDesk research@example.com"
@pytest.mark.unit
def test_a_throttle_lets_the_next_vendor_try(monkeypatch):
"""SEC throttles by refusing the request; the router then tries yfinance."""
import requests
from tradingagents.dataflows.errors import VendorUnavailableError
def _throttled(*a, **k):
raise requests.HTTPError(response=mock.Mock(status_code=429))
monkeypatch.setattr(sec_edgar.requests, "get", _throttled)
with pytest.raises(VendorUnavailableError):
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
@pytest.mark.unit
def test_values_do_not_break_the_columns():
"""Figures run to the billions; a thousands separator would split the field."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
assert _table(out)[1]["Total Assets"][-1] == "364980"
@pytest.mark.unit
def test_a_per_share_figure_keeps_its_own_unit():
"""Statements are reported in millions, but EPS is dollars per share: scaling
it the same way prints a real figure as zero."""
out = sec_edgar.get_income_statement("AAPL", "annual", "2024-11-15")
assert _table(out)[1]["Diluted EPS (USD/shares)"] == ["6.08"]
@pytest.mark.unit
def test_every_row_has_one_cell_per_column():
"""Every period's row lines up with the header, or the table is misread by position."""
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
table = [r for r in out.splitlines() if r and not r.startswith("#")]
widths = {row.count(",") for row in table}
assert len(widths) == 1, table
@pytest.mark.unit
def test_a_server_error_lets_the_next_vendor_try(monkeypatch):
import requests
from tradingagents.dataflows.errors import VendorError
def _server_error(*a, **k):
raise requests.HTTPError(response=mock.Mock(status_code=503))
monkeypatch.setattr(sec_edgar.requests, "get", _server_error)
with pytest.raises(VendorError): # not a bare HTTPError
_REAL_FETCH("https://data.sec.gov/api/xbrl/companyfacts/CIK0000320193.json")
@pytest.mark.unit
def test_older_periods_fall_back_to_the_tag_the_filer_used_then():
"""Filers renamed lines when the revenue standard changed, so one tag covers
only recent years. Each period takes one tag, never a sum of two."""
facts = {"Revenues": {"units": {"USD": [_fact("2015-09-26", 233_715_000_000, "2015-10-28",
start="2014-09-28")]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30")]}}}
values, unit = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", ((300, 400),))
assert values == {("2015-09-26", 0): 233_715_000_000, ("2024-09-28", 0): 391_035_000_000}
assert unit == "USD"
@pytest.mark.unit
def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both():
facts = {"Revenues": {"units": {"USD": [_fact("2024-09-28", 111, "2024-11-01", start="2023-09-30")]}},
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 999, "2024-11-01", start="2023-09-30")]}}}
values, _ = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", ((300, 400),))
assert values == {("2024-09-28", 0): 999}
@pytest.mark.unit
def test_the_default_identification_names_the_package_version(monkeypatch):
"""SEC asks automated clients to identify themselves; a release names its own version."""
import tradingagents
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
assert sec_edgar._user_agent() == f"TradingAgents/{tradingagents.__version__} (contact@example.com)"
@pytest.mark.unit
def test_capital_expenditure_is_found_under_either_tag_filers_use(monkeypatch):
"""NVIDIA and Amazon report purchases of productive assets, not of property and equipment."""
facts = {"facts": {"us-gaap": {"PaymentsToAcquireProductiveAssets": {"units": {"USD": [
_fact("2024-12-31", 70_000_000, "2025-02-10", start="2024-01-01")]}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "annual", "2025-03-01")
assert _table(out)[1]["Capital Expenditure"] == ["70"]
def _table(out):
"""(periods, {column: cells by period}) of a statement, one row per period."""
lines = [line for line in out.splitlines() if line and not line.startswith("#")]
header, rows = lines[0].split(","), [line.split(",") for line in lines[1:]]
return [row[0] for row in rows], {name: [row[i] for row in rows] for i, name in enumerate(header) if i}
def _columns(out):
return _table(out)[0]
@pytest.mark.unit
def test_an_annual_balance_sheet_has_no_quarter_end_columns():
"""A balance has no span, so a 10-Q's quarter-end balance passed as annual."""
annual = _columns(sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15"))
quarterly = _columns(sec_edgar.get_balance_sheet("AAPL", "quarterly", "2024-11-15"))
assert "2022-03-26" not in annual and "2024-09-28" in annual
assert "2022-03-26" in quarterly
@pytest.mark.unit
def test_a_twelve_month_total_from_a_quarterly_report_is_not_a_fiscal_year(monkeypatch):
"""Amazon's 10-Qs report trailing twelve months, which passed the annual span
check and read as fiscal years overlapping the real ones."""
facts = {"facts": {"us-gaap": {"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": [
_fact("2024-12-31", 115_000_000_000, "2025-02-07", start="2024-01-01"),
_fact("2025-03-31", 113_000_000_000, "2025-05-02", form="10-Q", fp="Q1", start="2024-04-01"),
]}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
assert _columns(sec_edgar.get_cashflow("AAPL", "annual", "2025-06-01")) == ["2024-12-31"]
@pytest.mark.unit
def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeypatch):
"""Filers recast past years in an 8-K after a split or spin-off. The annual
report decides the columns; the value is the latest filing of any form."""
facts = {"facts": {"us-gaap": {"EarningsPerShareDiluted": {"units": {"USD/shares": [
_fact("2017-03-31", 16.97, "2017-06-15", form="20-F", start="2016-04-01"),
_fact("2017-03-31", 2.12, "2019-09-30", form="6-K", start="2016-04-01"),
]}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
def eps(date):
out = sec_edgar.get_income_statement("AAPL", "annual", date)
return _table(out)[1]["Diluted EPS (USD/shares)"][0]
assert eps("2019-01-01") == "16.97"
assert eps("2020-01-01") == "2.12"
@pytest.mark.unit
def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch):
"""Many filers tag a 10-Q's cash flows only year to date (3, 6, then 9 months):
a quarterly table of discrete quarters alone would stop at each first quarter."""
ytd = [
_fact("2025-12-27", 30_000_000_000, "2026-01-30", form="10-Q", fp="Q1", start="2025-09-28"),
_fact("2026-03-28", 53_000_000_000, "2026-05-01", form="10-Q", fp="Q2", start="2025-09-28"),
_fact("2026-06-27", 81_000_000_000, "2026-08-01", form="10-Q", fp="Q3", start="2025-09-28"),
]
facts = {"facts": {"us-gaap": {"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": ytd}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"]
assert _table(out)[1]["Operating Cash Flow"] == ["30000", "53000", "81000"]
@pytest.mark.unit
def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_quarter(monkeypatch):
facts = {"facts": {"us-gaap": {
"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": [
_fact("2026-06-30", 120_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-01-01")]}},
"PaymentsToAcquirePropertyPlantAndEquipment": {"units": {"USD": [
_fact("2026-06-30", 10_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-04-01")]}},
}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
columns = _table(out)[1]
assert columns["Operating Cash Flow"] == ["", "120"]
assert columns["Capital Expenditure"] == ["10", ""]
@pytest.mark.unit
def test_a_statement_serves_every_period_one_row_each(monkeypatch):
"""A filer's whole history in a row per line item ran to 67 columns and was read
a column off (#1517); a row per period keeps each figure beside its date."""
quarters = [
_fact(f"{year}-{month:02d}-30", (year * 10 + month // 3) * 1_000_000, f"{year}-{month + 1:02d}-28",
form="10-Q", fp=f"Q{month // 3}", start=f"{year}-{month - 2:02d}-01")
for year in range(2015, 2026) for month in (3, 6, 9)
]
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": quarters}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
periods, columns = _table(sec_edgar.get_income_statement("AAPL", "quarterly", "2026-01-15"))
assert periods == [f"{y}-{m:02d}-30" for y in range(2015, 2026) for m in (3, 6, 9)]
assert columns["Revenue"][0] == "20151" and columns["Revenue"][-1] == "20253"
@pytest.mark.unit
def test_a_figure_under_a_million_is_not_printed_as_zero(monkeypatch):
facts = {"facts": {"us-gaap": {"Revenues": {"units": {"USD": [
_fact("2025-12-31", 303_300, "2026-02-20", start="2025-01-01"),
_fact("2024-12-31", -25_000, "2025-02-20", start="2024-01-01")]}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
assert _table(sec_edgar.get_income_statement("AAPL", "annual", "2026-03-01"))[1]["Revenue"] == ["-0.0250", "0.303"]