From a9cc3be7312ca2c36a4b35821adcb4815a838438 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Wed, 23 Sep 2026 19:28:56 +0000 Subject: [PATCH] fix(sec_edgar): list only fiscal years in annual statements - annual columns are the periods an annual report covers, so 10-Q balances and twelve-month totals no longer read as fiscal years - a value is still the latest filing of any form, so a later recast counts from its filing date --- tests/test_sec_edgar.py | 45 ++++++++++++++++++++++++++++ tradingagents/dataflows/sec_edgar.py | 19 ++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/tests/test_sec_edgar.py b/tests/test_sec_edgar.py index a6c55333c..eaebcc83e 100644 --- a/tests/test_sec_edgar.py +++ b/tests/test_sec_edgar.py @@ -248,3 +248,48 @@ def test_capital_expenditure_is_found_under_either_tag_filers_use(monkeypatch): lambda url: TICKER_MAP if "company_tickers" in url else facts) out = sec_edgar.get_cashflow("AAPL", "annual", "2025-03-01") assert [r for r in out.splitlines() if r.startswith("Capital Expenditure")] == ["Capital Expenditure,70"] + + +def _columns(out): + return [line for line in out.splitlines() if line.startswith(",")][0].split(",")[1:] + + +@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 [line for line in out.splitlines() if line.startswith("Diluted EPS")][0].split(",")[1] + + assert eps("2019-01-01") == "16.97" + assert eps("2020-01-01") == "2.12" diff --git a/tradingagents/dataflows/sec_edgar.py b/tradingagents/dataflows/sec_edgar.py index 7a4eca19e..44161e0d5 100644 --- a/tradingagents/dataflows/sec_edgar.py +++ b/tradingagents/dataflows/sec_edgar.py @@ -76,6 +76,12 @@ _STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = { # end date, so a match on the end date alone can report half a year as a quarter. _SPANS = {"quarterly": (60, 115), "annual": (300, 400)} +# A fiscal year is a period an annual report covers. A 10-Q balance has no span +# to reject, and some filers' 10-Qs report twelve-month totals that pass the span +# check, so either would read as a fiscal year. The value is still the latest +# filing of any form: a recast after a split or spin-off counts from its filing. +_ANNUAL_FORMS = ("10-K", "20-F", "40-F") + def _user_agent() -> str: """Who SEC sees. No account or key exists; callers identify themselves. @@ -138,7 +144,8 @@ def cik_for(ticker: str) -> str | None: return None -def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int]) -> tuple[dict, str]: +def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, int], + forms: tuple[str, ...] = ()) -> tuple[dict, str]: """({period end: value}, unit) for the first tag the filer reports, as known then. A period reported more than once takes its latest filing on or before the @@ -155,6 +162,7 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, for tag in tags: for unit, unit_values in ((facts.get(tag) or {}).get("units", {})).items(): latest: dict[str, dict] = {} + covered: set[str] = set() # period ends a filing of ``forms`` reports for fact in unit_values: if fact["filed"] > curr_date or fact["end"] in values: continue @@ -164,9 +172,12 @@ def _as_of(facts: dict, tags: tuple[str, ...], curr_date: str, span: tuple[int, days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days if not low <= days <= high: continue + if not forms or fact.get("form", "").startswith(forms): + covered.add(fact["end"]) seen = latest.get(fact["end"]) if seen is None or fact["filed"] >= seen["filed"]: latest[fact["end"]] = fact + latest = {end: fact for end, fact in latest.items() if end in covered} if latest: chosen_unit = unit values.update({end: fact["val"] for end, fact in latest.items()}) @@ -184,8 +195,10 @@ def _statement(kind: str, ticker: str, freq: str, curr_date: str, title: str) -> if not us_gaap: raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts") - span = _SPANS["quarterly" if freq.lower() == "quarterly" else "annual"] - lines = {label: _as_of(us_gaap, tags, curr_date, span) for label, tags in _STATEMENTS[kind]} + quarterly = freq.lower() == "quarterly" + span = _SPANS["quarterly" if quarterly else "annual"] + forms = () if quarterly else _ANNUAL_FORMS + lines = {label: _as_of(us_gaap, tags, curr_date, span, forms) for label, tags in _STATEMENTS[kind]} periods = sorted({end for values, _ in lines.values() for end in values}) if not periods: raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {curr_date}")