fix(dataflows): withhold the live profile from historical fundamentals

- both fundamentals vendors accepted curr_date and ignored it, serving a
  present-day company profile into a run dated in the past: yfinance via
  Ticker.info, Alpha Vantage via OVERVIEW
- that profile has no historical vintage, not even name/sector/industry (which
  move when a company renames or is reclassified), so a past curr_date now
  withholds it and says why; live runs are unchanged
- the rule lives once in date_window next to the existing look-ahead helpers,
  so switching data_vendors between the two cannot reintroduce the leak, and
  the guard runs before the request rather than discarding a paid-for response
- point-in-time fundamentals for a past date already come from the balance
  sheet, income statement and cash flow tools, which filter on curr_date #1300
This commit is contained in:
Yijia-Xiao
2026-09-07 20:54:19 +00:00
parent 9dee508c44
commit 96111aa368
4 changed files with 193 additions and 7 deletions

View File

@@ -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})

View File

@@ -1,6 +1,7 @@
import json import json
from .alpha_vantage_common import _make_api_request from .alpha_vantage_common import _make_api_request
from .date_window import withhold_live_profile
def _filter_reports_by_date(result, curr_date: str): 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. 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: Args:
ticker (str): Ticker symbol of the company 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: Returns:
str: Company overview data including financial ratios and key metrics str: Company overview data including financial ratios and key metrics
""" """
withheld = withhold_live_profile(curr_date, ticker)
if withheld:
return withheld
params = { params = {
"symbol": ticker, "symbol": ticker,
} }

View File

@@ -13,6 +13,8 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from .utils import get_current_date
def to_utc(dt: datetime) -> datetime: def to_utc(dt: datetime) -> datetime:
"""Normalize a datetime to UTC-aware; a naive value is assumed to be UTC.""" """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: if pub_dt is not None:
return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1) return to_utc(start_dt) <= to_utc(pub_dt) < end + timedelta(days=1)
return end >= datetime.now(timezone.utc) - 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."
)

View File

@@ -5,6 +5,7 @@ import pandas as pd
import yfinance as yf import yfinance as yf
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from .date_window import withhold_live_profile
from .stockstats_utils import ( from .stockstats_utils import (
StockstatsUtils, StockstatsUtils,
_assert_ohlcv_not_stale, _assert_ohlcv_not_stale,
@@ -273,10 +274,22 @@ def get_stockstats_indicator(
def get_fundamentals( def get_fundamentals(
ticker: Annotated[str, "ticker symbol of the company"], 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) 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: try:
ticker_obj = yf.Ticker(canonical) ticker_obj = yf.Ticker(canonical)
info = yf_retry(lambda: ticker_obj.info) info = yf_retry(lambda: ticker_obj.info)
@@ -315,10 +328,7 @@ def get_fundamentals(
("Free Cash Flow", info.get("freeCashflow")), ("Free Cash Flow", info.get("freeCashflow")),
] ]
lines = [] lines = [f"{label}: {v}" for label, v in fields if v is not None]
for label, value in fields:
if value is not None:
lines.append(f"{label}: {value}")
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for # yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
# unknown symbols, so `info` is truthy but every field is empty. Treat # unknown symbols, so `info` is truthy but every field is empty. Treat