mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-05 17:07:13 +02:00
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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user