From d7ac9461dafaaeb58c0dd0e9182afcc6951c8ecb Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Mon, 28 Sep 2026 21:10:27 +0000 Subject: [PATCH] fix(dataflows): serve a past run financial statements only with a known filing date - Yahoo and Alpha Vantage statements, dated by the period they cover, are withheld from a run dated in the past, with a notice saying why - statements come from SEC EDGAR by default (fundamental_data "sec_edgar,yfinance"), so a past run reads US filers as filed --- README.md | 8 +-- tests/test_alpha_vantage_hardening.py | 18 ------ tests/test_dataflows_config.py | 4 +- tests/test_statement_knowledge_time.py | 60 +++++++++++++++++++ tests/test_undated_tools_as_of.py | 13 ++-- tradingagents/dataflows/date_window.py | 24 +++++++- .../vendors/alpha_vantage/fundamentals.py | 50 +++++----------- .../dataflows/vendors/yahoo/fundamentals.py | 37 +++--------- tradingagents/default_config.py | 4 +- 9 files changed, 119 insertions(+), 99 deletions(-) create mode 100644 tests/test_statement_knowledge_time.py diff --git a/README.md b/README.md index 378804c5d..8510ce373 100644 --- a/README.md +++ b/README.md @@ -280,13 +280,9 @@ See `tradingagents/default_config.py` for all configuration options. ### Fundamentals as filed -US company statements can come from SEC EDGAR, which records the date every figure was filed. A run dated in the past then reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B. +US company statements come from SEC EDGAR, which records the date every figure was filed. A run dated in the past reads the statements exactly as they stood that day: a fiscal year that has ended but has not been filed yet is not served, and a figure restated later still reads as first reported. Apple's 2008 total assets were filed as $39.6B and restated to $36.2B in 2010, so a run dated in between reads $39.6B. EDGAR needs no account or API key. -EDGAR needs no account or API key. Add the vendor to the chain: - -```python -config["data_vendors"]["fundamental_data"] = "sec_edgar,yfinance" -``` +Other companies' statements come from Yahoo Finance, which dates a statement by the period it covers rather than by when it was published. A run dated today reads them; a run dated in the past is told they are withheld, since Yahoo cannot say which figures were public by then. SEC asks callers to identify themselves and refuses requests that carry no contact address, so a default one is sent. Set your own so SEC can reach you rather than the project: diff --git a/tests/test_alpha_vantage_hardening.py b/tests/test_alpha_vantage_hardening.py index f60f0b095..02c73e933 100644 --- a/tests/test_alpha_vantage_hardening.py +++ b/tests/test_alpha_vantage_hardening.py @@ -77,30 +77,12 @@ _FUNDAMENTALS_JSON = json.dumps({ }) -@pytest.mark.unit -def test_fundamentals_look_ahead_filter_runs_on_json_string(monkeypatch): - # #1115: the payload arrives as a JSON *string*; the old dict-only guard let - # future-dated fiscal periods leak into historical runs. - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) - out = avf.get_balance_sheet("AAPL", as_of_date="2024-01-01") - assert isinstance(out, str) # callers still receive a str - parsed = json.loads(out) - assert [r["fiscalDateEnding"] for r in parsed["annualReports"]] == ["2023-12-31"] - assert [r["fiscalDateEnding"] for r in parsed["quarterlyReports"]] == ["2023-09-30"] - - @pytest.mark.unit def test_fundamentals_no_curr_date_passes_through(monkeypatch): monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: _FUNDAMENTALS_JSON) assert avf.get_income_statement("AAPL") == _FUNDAMENTALS_JSON -@pytest.mark.unit -def test_fundamentals_non_json_body_unchanged(monkeypatch): - monkeypatch.setattr(avf, "_make_api_request", lambda fn, params: "not-json") - assert avf.get_cashflow("AAPL", as_of_date="2024-01-01") == "not-json" - - # --------------------------------------------------------------------------- # Date trim (see the rationale on the unguarded trim in alpha_vantage.common) # --------------------------------------------------------------------------- diff --git a/tests/test_dataflows_config.py b/tests/test_dataflows_config.py index dd0393c27..03b691e92 100644 --- a/tests/test_dataflows_config.py +++ b/tests/test_dataflows_config.py @@ -49,7 +49,7 @@ class DataflowsConfigIsolationTests(unittest.TestCase): fresh = get_config() self.assertEqual(fresh["data_vendors"]["core_stock_apis"], "alpha_vantage") self.assertEqual(fresh["data_vendors"]["technical_indicators"], "yfinance") - self.assertEqual(fresh["data_vendors"]["fundamental_data"], "yfinance") + self.assertEqual(fresh["data_vendors"]["fundamental_data"], "sec_edgar,yfinance") self.assertEqual(fresh["data_vendors"]["news_data"], "yfinance") def test_nested_dict_updates_merge_one_level_deep(self): @@ -95,7 +95,7 @@ def test_a_run_reads_its_own_graphs_vendors_not_the_last_graph_built(): set_config(first) # graph A is built second = _graph(copy.deepcopy(default_config.DEFAULT_CONFIG)) - assert _vendors_seen_by_a_run(second) == ["yfinance"] + assert _vendors_seen_by_a_run(second) == ["sec_edgar,yfinance"] @pytest.mark.unit diff --git a/tests/test_statement_knowledge_time.py b/tests/test_statement_knowledge_time.py new file mode 100644 index 000000000..6c18ed91b --- /dev/null +++ b/tests/test_statement_knowledge_time.py @@ -0,0 +1,60 @@ +"""A historical run is served statements only with a known filing time. + +SEC EDGAR dates every filing, so it serves a past date as filed. Yahoo and Alpha +Vantage date a statement by the period it covers, which a company publishes weeks +later, so a past run is told they are withheld rather than served figures that may +not yet have been public. +""" + +import copy +from unittest import mock + +import pytest + +from tradingagents import default_config +from tradingagents.dataflows import router +from tradingagents.dataflows.config import set_config +from tradingagents.dataflows.vendors.alpha_vantage import fundamentals as alpha_vantage +from tradingagents.dataflows.vendors.yahoo import fundamentals as yahoo + +PAST = "2024-03-14" + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [yahoo.get_balance_sheet, yahoo.get_cashflow, yahoo.get_income_statement]) +def test_yahoo_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(yahoo, "yf_retry", side_effect=AssertionError("requested")): + out = statement("SAP.DE", "quarterly", PAST) + assert "withheld" in out and PAST in out and "filing date" in out + + +@pytest.mark.unit +@pytest.mark.parametrize("statement", [alpha_vantage.get_balance_sheet, alpha_vantage.get_cashflow, + alpha_vantage.get_income_statement]) +def test_alpha_vantage_withholds_statements_from_a_past_run_without_asking(statement): + with mock.patch.object(alpha_vantage, "_make_api_request", side_effect=AssertionError("requested")): + out = statement("IBM", "quarterly", PAST) + assert "withheld" in out and PAST in out + + +@pytest.mark.unit +def test_a_current_run_is_still_served_yahoo_statements(): + import pandas as pd + + from tradingagents.dataflows.date_window import get_current_date + + frame = pd.DataFrame({pd.Timestamp("2026-06-30"): [1.0]}, index=["Total Assets"]) + with mock.patch.object(yahoo, "yf_retry", return_value=frame): + out = yahoo.get_balance_sheet("SAP.DE", "quarterly", get_current_date()) + assert "Total Assets" in out and "withheld" not in out + + +@pytest.mark.unit +def test_statements_come_from_sec_edgar_first_by_default(): + set_config(copy.deepcopy(default_config.DEFAULT_CONFIG)) + served = [] + chain = {name: (lambda *a, _n=name, **k: served.append(_n) or f"{_n} statements") + for name in router.VENDOR_METHODS["get_balance_sheet"]} + with mock.patch.dict(router.VENDOR_METHODS, {"get_balance_sheet": chain}): + router.route_to_vendor("get_balance_sheet", "AAPL", "quarterly", PAST) + assert served == ["sec_edgar"] diff --git a/tests/test_undated_tools_as_of.py b/tests/test_undated_tools_as_of.py index a75a7276c..bd4aa01c3 100644 --- a/tests/test_undated_tools_as_of.py +++ b/tests/test_undated_tools_as_of.py @@ -160,9 +160,10 @@ def test_an_indicator_that_could_not_be_read_is_not_shown_as_a_blank_value(): # A past date withholds the live profile before any request, so the # fundamentals case is exercised on the date it does fetch. ("get_fundamentals", ("AAPL", None)), - ("get_balance_sheet", ("AAPL", "annual", "2026-09-01")), - ("get_cashflow", ("AAPL", "annual", "2026-09-01")), - ("get_income_statement", ("AAPL", "annual", "2026-09-01")), + # Statements are requested only for a run dated today; a past one withholds them. + ("get_balance_sheet", ("AAPL", "annual", get_current_date())), + ("get_cashflow", ("AAPL", "annual", get_current_date())), + ("get_income_statement", ("AAPL", "annual", get_current_date())), ("get_insider_transactions", ("AAPL", "2026-09-01")), ]) def test_a_yfinance_failure_is_a_vendor_error_not_a_report(func, args): @@ -206,11 +207,11 @@ def test_an_unreachable_vendor_is_not_reported_as_a_missing_symbol(monkeypatch): monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: False) with pytest.raises(VendorUnavailableError, match="unreachable"): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) monkeypatch.setattr(ohlcv, "vendor_reachable", lambda url: True) with pytest.raises(NoMarketDataError): - yahoo_fundamentals.get_balance_sheet("AAPL", "annual", "2026-09-01") + yahoo_fundamentals.get_balance_sheet("AAPL", "annual", get_current_date()) @pytest.mark.unit @@ -218,11 +219,13 @@ def test_every_vendor_unavailable_says_so_rather_than_crashing(monkeypatch): """A throttled or unreachable chain used to raise RuntimeError('No available vendor'), which ends the run, and never said the vendor was the problem.""" from tradingagents.dataflows import router + from tradingagents.dataflows.config import set_config from tradingagents.dataflows.errors import VendorUnavailableError def _down(*a, **k): raise VendorUnavailableError("Yahoo Finance is unreachable") + set_config({"data_vendors": {"fundamental_data": "yfinance"}}) monkeypatch.setitem(router.VENDOR_METHODS["get_balance_sheet"], "yfinance", _down) out = router.route_to_vendor("get_balance_sheet", "AAPL", "annual", "2026-09-01") diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index e799914a0..1b8558f21 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -122,6 +122,26 @@ def withhold_live_profile(as_of_date: str | None, label: str) -> str | None: f"today's quote, and even the name, sector and industry reflect today " f"rather than {as_of_date} (companies rename and get reclassified). " f"Serving them would put post-decision information into a {as_of_date} " - f"analysis. Point-in-time fundamentals for {as_of_date} are available " - f"from the balance sheet, income statement, and cash flow tools." + f"analysis. Statements filed by {as_of_date} are served as filed where " + f"the filing date is known (SEC EDGAR, for US filers)." + ) + + +def withhold_undated_statements(as_of_date: str | None, label: str, title: str) -> str | None: + """Notice to serve instead of a statement with no filing date, or None to serve it. + + A vendor that dates a statement by the period it covers (Yahoo, Alpha + Vantage) cannot say when its figures became public: a company files weeks + after the period ends, so a run dated in that gap would read figures that + were not yet known. A past run is told so; SEC EDGAR, which dates every + filing, serves US filers as filed. + """ + if not is_historical(as_of_date): + return None + return ( + f"# {title} for {label}\n" + f"# Point-in-time as of: {as_of_date}\n\n" + f"{title} data is withheld for this date. This vendor dates a statement by " + f"the period it covers and reports no filing date, so it cannot show which " + f"figures were public on {as_of_date}. SEC EDGAR serves US filers as filed." ) diff --git a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py index 17a304ed5..8cc90b5ae 100644 --- a/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py +++ b/tradingagents/dataflows/vendors/alpha_vantage/fundamentals.py @@ -1,41 +1,15 @@ -import json - -from tradingagents.dataflows.date_window import withhold_live_profile +from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements from tradingagents.dataflows.vendors.alpha_vantage.common import _make_api_request -def _filter_reports_by_date(result, as_of_date: str): - """Drop annual/quarterly reports dated after as_of_date to prevent look-ahead. - - ``_make_api_request`` returns the fundamentals payload as a JSON string, so - parse, filter, and re-serialize. A non-JSON body or an unset ``as_of_date`` is - returned unchanged. - """ - if not as_of_date or not isinstance(result, str): - return result - try: - payload = json.loads(result) - except json.JSONDecodeError: - return result - if not isinstance(payload, dict): - return result - for key in ("annualReports", "quarterlyReports"): - if isinstance(payload.get(key), list): - payload[key] = [ - r for r in payload[key] - if r.get("fiscalDateEnding", "") <= as_of_date - ] - return json.dumps(payload) - - def get_fundamentals(ticker: str, as_of_date: str = None) -> str: """ Retrieve comprehensive fundamental data for a given ticker symbol using Alpha Vantage. OVERVIEW serves only present-day values and carries no historical vintage, so a past ``as_of_date`` withholds it rather than leaking post-decision figures - into a backtest (#1300); the statement endpoints below stay point-in-time via - ``_filter_reports_by_date``. + into a backtest (#1300); so are the statement endpoints below, which carry + no filing date. Args: ticker (str): Ticker symbol of the company @@ -57,18 +31,24 @@ def get_fundamentals(ticker: str, as_of_date: str = None) -> str: def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve balance sheet data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("BALANCE_SHEET", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Balance Sheet") + if withheld: + return withheld + return _make_api_request("BALANCE_SHEET", {"symbol": ticker}) def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve cash flow statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("CASH_FLOW", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Cash Flow") + if withheld: + return withheld + return _make_api_request("CASH_FLOW", {"symbol": ticker}) def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str = None): """Retrieve income statement data for a given ticker symbol using Alpha Vantage.""" - result = _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) - return _filter_reports_by_date(result, as_of_date) + withheld = withhold_undated_statements(as_of_date, ticker, "Income Statement") + if withheld: + return withheld + return _make_api_request("INCOME_STATEMENT", {"symbol": ticker}) diff --git a/tradingagents/dataflows/vendors/yahoo/fundamentals.py b/tradingagents/dataflows/vendors/yahoo/fundamentals.py index 3bbcdb890..8fa5a0a5c 100644 --- a/tradingagents/dataflows/vendors/yahoo/fundamentals.py +++ b/tradingagents/dataflows/vendors/yahoo/fundamentals.py @@ -3,7 +3,7 @@ from typing import Annotated import pandas as pd import yfinance as yf -from tradingagents.dataflows.date_window import withhold_live_profile +from tradingagents.dataflows.date_window import withhold_live_profile, withhold_undated_statements from tradingagents.dataflows.errors import VendorUnavailableError from tradingagents.dataflows.net import vendor_reachable from tradingagents.dataflows.symbols import normalize_symbol @@ -84,27 +84,18 @@ def get_fundamentals( return f"# Company Fundamentals for {canonical}\n\n" + "\n".join(lines) -# This vendor dates a statement by the period it covers, not by the day it was -# filed, and carries no filing date to do better. A company files weeks after its -# period ends, so a run dated in that gap can be served figures that were not yet -# public. Say so rather than implying the stricter guarantee (SEC EDGAR, which -# does carry filing dates, serves US filers as filed). -_PERIOD_END_VINTAGE = ( - "# Periods are cut at the fiscal period end; this vendor does not report " - "filing dates, so the most recent period may not have been published yet.\n\n" -) - - def _statement(ticker, freq, as_of_date, title, quarterly_attr, annual_attr) -> str: - """One financial statement as CSV, cut at ``as_of_date`` by period end.""" + """One financial statement as CSV, for a run dated today.""" canonical = normalize_symbol(ticker) + withheld = withhold_undated_statements(as_of_date, canonical, title) + if withheld: + return withheld what = title.lower() attr = quarterly_attr if freq.lower() == "quarterly" else annual_attr data = yf_retry(lambda: getattr(yf.Ticker(canonical), attr)) - data = pd.DataFrame() if data is None else filter_financials_by_date(data, as_of_date) - if data.empty: + if data is None or data.empty: raise_for_empty(ticker, canonical, f"{what} data") - return f"# {title} data for {canonical} ({freq})\n" + _PERIOD_END_VINTAGE + data.to_csv() + return f"# {title} data for {canonical} ({freq})\n" + data.to_csv() def get_balance_sheet( @@ -176,17 +167,3 @@ def get_insider_transactions( def get_company_profile(ticker: str) -> dict: """Yahoo's current profile for ``ticker``: name, sector, industry and the like.""" return yf_retry(lambda: yf.Ticker(normalize_symbol(ticker)).info) or {} - - -def filter_financials_by_date(data: pd.DataFrame, as_of_date: str) -> pd.DataFrame: - """Drop financial statement columns (fiscal period timestamps) after as_of_date. - - yfinance financial statements use fiscal period end dates as columns. - Columns after as_of_date represent future data and are removed to - prevent look-ahead bias. - """ - if not as_of_date or data.empty: - return data - cutoff = pd.Timestamp(as_of_date) - mask = pd.to_datetime(data.columns, errors="coerce") <= cutoff - return data.loc[:, mask] diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index a79c819b2..49ecf7875 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -147,7 +147,9 @@ def build_default_config() -> dict: "data_vendors": { "core_stock_apis": "yfinance", # Options: alpha_vantage, yfinance "technical_indicators": "yfinance", # Options: alpha_vantage, yfinance - "fundamental_data": "yfinance", # Options: alpha_vantage, yfinance + # Statements come from SEC EDGAR as filed (US filers), then Yahoo; the + # overview and insider tools, which SEC EDGAR does not serve, from Yahoo. + "fundamental_data": "sec_edgar,yfinance", # Options: sec_edgar, alpha_vantage, yfinance "news_data": "yfinance", # Options: alpha_vantage, yfinance "macro_data": "fred", # Options: fred (needs FRED_API_KEY) "prediction_markets": "polymarket", # Options: polymarket (keyless)