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
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 1d92269d35
commit d7ac9461da
9 changed files with 119 additions and 99 deletions
+2 -6
View File
@@ -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:
-18
View File
@@ -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)
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -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
+60
View File
@@ -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"]
+8 -5
View File
@@ -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")
+22 -2
View File
@@ -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."
)
+15 -35
View File
@@ -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})
+7 -30
View File
@@ -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]
+3 -1
View File
@@ -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)