mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 11:15:24 +03:00
- statements are limited to facts filed by the run's date, at the values filed then - quarterly figures are picked by span, so a year to date total is not read as a quarter - a fourth quarter is reported unavailable rather than derived - opt in with sec_edgar in the fundamental_data chain; a non-filer falls through
240 lines
10 KiB
Python
240 lines
10 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 import sec_edgar
|
|
from tradingagents.dataflows.errors import NoMarketDataError
|
|
|
|
_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_unavailable():
|
|
out = sec_edgar.get_balance_sheet("AAPL", "annual", "2024-11-15")
|
|
assert "Stockholders Equity" in out and "unavailable" in out
|
|
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 VendorRateLimitError
|
|
|
|
def _throttled(*a, **k):
|
|
raise requests.HTTPError(response=mock.Mock(status_code=429))
|
|
|
|
monkeypatch.setattr(sec_edgar.requests, "get", _throttled)
|
|
with pytest.raises(VendorRateLimitError):
|
|
_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")
|
|
body = [row for row in out.splitlines() if row.startswith("Total Assets")][0]
|
|
assert body.count(",") == out.splitlines()[3].count(",")
|
|
|
|
|
|
@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")
|
|
row = [r for r in out.splitlines() if r.startswith("Diluted EPS")][0]
|
|
assert "6.08" in row
|
|
assert "USD/shares" in row or "per share" in row
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_every_row_has_one_cell_per_period():
|
|
"""An untagged line still has to line up with the columns, 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": 233_715_000_000, "2024-09-28": 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": 999}
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_default_identification_tracks_the_installed_version(monkeypatch):
|
|
"""A release should identify itself, not a version frozen in the source."""
|
|
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
|
|
monkeypatch.setattr(sec_edgar.metadata, "version", lambda name: "9.9.9")
|
|
assert sec_edgar._user_agent() == "TradingAgents/9.9.9 (contact@example.com)"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_an_uninstalled_checkout_still_identifies_itself(monkeypatch):
|
|
monkeypatch.delenv("SEC_EDGAR_USER_AGENT", raising=False)
|
|
|
|
def _missing(name):
|
|
raise sec_edgar.metadata.PackageNotFoundError(name)
|
|
|
|
monkeypatch.setattr(sec_edgar.metadata, "version", _missing)
|
|
assert "@" in sec_edgar._user_agent()
|