diff --git a/tests/test_fundamentals_lookahead.py b/tests/test_fundamentals_lookahead.py new file mode 100644 index 000000000..696b14872 --- /dev/null +++ b/tests/test_fundamentals_lookahead.py @@ -0,0 +1,130 @@ +"""Historical fundamentals must not leak a live company profile (#1300). + +Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage +``OVERVIEW``) serve only present-day values: market cap, valuation multiples, +the 52-week range and TTM income all move with today's quote, and even name, +sector and industry shift when a company renames or is reclassified. None of it +carries a historical vintage, so emitting it into a run dated in the past puts +post-decision information into the analyst's context, in the same family as the +FRED (#1275), social (#1220) and memory (#1251) leaks. + +Both vendors withhold on one shared rule (``date_window.withhold_live_profile``) +so switching ``fundamental_data`` between them cannot reintroduce the leak. The +statement tools stay point-in-time by filtering on ``curr_date``, and a live run +is unchanged. All API access is mocked. +""" +from __future__ import annotations + +from unittest import mock + +import pytest + +from tradingagents.dataflows import alpha_vantage_fundamentals as av, date_window, y_finance + +_TODAY = "2026-09-07" +_PAST = "2024-05-10" + +# A profile payload mixing stable-looking identity fields with market-dependent ones. +_INFO = { + "longName": "Apple Inc.", + "sector": "Technology", + "industry": "Consumer Electronics", + "marketCap": 3_500_000_000_000, + "trailingPE": 34.2, + "fiftyTwoWeekHigh": 260.1, + "totalRevenue": 391_000_000_000, +} + +# Values that must never reach a historical run. +_LEAKY = ("3500000000000", "34.2", "260.1", "391000000000", + "Apple Inc.", "Technology", "Consumer Electronics") + + +def _yf(curr_date, info=_INFO, today=_TODAY): + with mock.patch.object(date_window, "get_current_date", return_value=today), \ + mock.patch.object(y_finance, "yf_retry", lambda fn: info), \ + mock.patch.object(y_finance.yf, "Ticker"): + return y_finance.get_fundamentals("AAPL", curr_date) + + +def _av(curr_date, today=_TODAY): + """Alpha Vantage path; the API call is mocked so a leak would be visible.""" + with mock.patch.object(date_window, "get_current_date", return_value=today), \ + mock.patch.object(av, "_make_api_request", + return_value="MarketCapitalization: 3500000000000") as req: + return av.get_fundamentals("AAPL", curr_date), req + + +@pytest.mark.unit +class TestYFinanceHistoricalRun: + def test_no_profile_value_survives(self): + out = _yf(_PAST) + for leaked in _LEAKY: + assert leaked not in out, f"leaked live-profile value {leaked!r}" + + def test_states_the_as_of_date_and_explains_itself(self): + # The analyst must be told why the figures are absent, so it does not + # read the gap as a real signal or fabricate around it. + out = _yf(_PAST) + assert f"Point-in-time as of: {_PAST}" in out + assert "withheld" in out + assert _PAST in out and _TODAY in out + + def test_no_wall_clock_retrieval_stamp(self): + # The old header stamped datetime.now(), which is what surfaced the leak. + assert "Data retrieved on:" not in _yf(_PAST) + + def test_the_request_is_not_even_made(self): + # The response would only be discarded; skipping it also avoids burning + # vendor quota on a call whose result cannot be used. + with mock.patch.object(date_window, "get_current_date", return_value=_TODAY), \ + mock.patch.object(y_finance.yf, "Ticker") as tk: + y_finance.get_fundamentals("AAPL", _PAST) + tk.assert_not_called() + + +@pytest.mark.unit +class TestAlphaVantageHistoricalRun: + """The same rule must hold for the other fundamentals vendor, or switching + data_vendors["fundamental_data"] would silently reintroduce the leak.""" + + def test_overview_is_withheld(self): + out, _ = _av(_PAST) + assert "3500000000000" not in out + assert "withheld" in out + assert f"Point-in-time as of: {_PAST}" in out + + def test_the_api_call_is_not_made(self): + _, req = _av(_PAST) + req.assert_not_called() + + def test_live_run_still_calls_the_api(self): + out, req = _av(_TODAY) + req.assert_called_once() + assert "3500000000000" in out + + +@pytest.mark.unit +class TestLiveRunUnchanged: + def test_yfinance_current_date_returns_the_full_profile(self): + out = _yf(_TODAY) + for value in _LEAKY: + assert value in out + assert "Data retrieved on:" in out + assert "withheld" not in out + + def test_yfinance_absent_curr_date_returns_the_full_profile(self): + out = _yf(None) + assert "Market Cap: 3500000000000" in out + assert "withheld" not in out + + +@pytest.mark.unit +class TestNoUsableFieldsStillRaises: + def test_stub_payload_raises_no_market_data(self): + # yfinance returns {"trailingPegRatio": None} for unknown symbols; on a + # live run that must stay a hard "no data", not a bare header. + from tradingagents.dataflows.symbol_utils import NoMarketDataError + + with pytest.raises(NoMarketDataError): + _yf(_TODAY, info={"trailingPegRatio": None}) diff --git a/tradingagents/dataflows/alpha_vantage_fundamentals.py b/tradingagents/dataflows/alpha_vantage_fundamentals.py index 90c89f204..56525b3ff 100644 --- a/tradingagents/dataflows/alpha_vantage_fundamentals.py +++ b/tradingagents/dataflows/alpha_vantage_fundamentals.py @@ -1,6 +1,7 @@ import json from .alpha_vantage_common import _make_api_request +from .date_window import withhold_live_profile def _filter_reports_by_date(result, curr_date: str): @@ -31,13 +32,22 @@ def get_fundamentals(ticker: str, curr_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 ``curr_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``. + Args: ticker (str): Ticker symbol of the company - curr_date (str): Current date you are trading at, yyyy-mm-dd (not used for Alpha Vantage) + curr_date (str): Analysis date, yyyy-mm-dd Returns: str: Company overview data including financial ratios and key metrics """ + withheld = withhold_live_profile(curr_date, ticker) + if withheld: + return withheld + params = { "symbol": ticker, } diff --git a/tradingagents/dataflows/date_window.py b/tradingagents/dataflows/date_window.py index 8b49dca26..cd27cdc32 100644 --- a/tradingagents/dataflows/date_window.py +++ b/tradingagents/dataflows/date_window.py @@ -13,6 +13,8 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone +from .utils import get_current_date + def to_utc(dt: datetime) -> datetime: """Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.""" @@ -28,3 +30,37 @@ def in_window(pub_dt: datetime | None, start_dt: datetime, end_dt: datetime) -> if pub_dt is not None: return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1) return end >= datetime.now(timezone.utc) - timedelta(days=1) + + +def withhold_live_profile(curr_date: str | None, label: str) -> str | None: + """Notice to serve instead of a live-only company profile, or None to serve it. + + Vendor "company overview" endpoints (yfinance ``Ticker.info``, Alpha Vantage + ``OVERVIEW``) return only present-day values: market cap, valuation + multiples, the 52-week range and TTM income all move with today's quote, and + even name, sector and industry shift when a company renames or is + reclassified. None of it carries a historical vintage, so serving it into a + run dated in the past puts post-decision information into the analyst's + context (#1300). + + Centralized so every fundamentals vendor withholds on the same rule and says + the same thing; point-in-time statements come from the balance sheet, income + statement and cash flow tools, which filter on ``curr_date``. + """ + if not curr_date: + return None + today = get_current_date() + if curr_date >= today: + return None + return ( + f"# Company Fundamentals for {label}\n" + f"# Point-in-time as of: {curr_date}\n\n" + f"Profile fundamentals are withheld for this date. This vendor serves " + f"only present-day values ({today}) with no historical vintage: market " + f"cap, valuation multiples, the 52-week range and TTM income move with " + f"today's quote, and even the name, sector and industry reflect today " + f"rather than {curr_date} (companies rename and get reclassified). " + f"Serving them would put post-decision information into a {curr_date} " + f"analysis. Point-in-time fundamentals for {curr_date} are available " + f"from the balance sheet, income statement, and cash flow tools." + ) diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index fdb49d52d..7f3f75f90 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -5,6 +5,7 @@ import pandas as pd import yfinance as yf from dateutil.relativedelta import relativedelta +from .date_window import withhold_live_profile from .stockstats_utils import ( StockstatsUtils, _assert_ohlcv_not_stale, @@ -273,10 +274,22 @@ def get_stockstats_indicator( def get_fundamentals( ticker: Annotated[str, "ticker symbol of the company"], - curr_date: Annotated[str, "current date (not used for yfinance)"] = None + curr_date: Annotated[str, "analysis date in YYYY-MM-DD format"] = None ): - """Get company fundamentals overview from yfinance.""" + """Get company fundamentals overview from yfinance. + + ``Ticker.info`` is a present-day snapshot with no historical vintage, so a + past ``curr_date`` withholds it through the shared point-in-time guard + (``date_window.withhold_live_profile``, #1300). + """ canonical = normalize_symbol(ticker) + + # Guard before the request: the response would only be discarded, and the + # answer does not depend on it. + withheld = withhold_live_profile(curr_date, canonical) + if withheld: + return withheld + try: ticker_obj = yf.Ticker(canonical) info = yf_retry(lambda: ticker_obj.info) @@ -315,10 +328,7 @@ def get_fundamentals( ("Free Cash Flow", info.get("freeCashflow")), ] - lines = [] - for label, value in fields: - if value is not None: - lines.append(f"{label}: {value}") + lines = [f"{label}: {v}" for label, v in fields if v is not None] # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for # unknown symbols, so `info` is truthy but every field is empty. Treat