mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-19 11:15:24 +03:00
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:
130
tests/test_fundamentals_lookahead.py
Normal file
130
tests/test_fundamentals_lookahead.py
Normal 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})
|
||||
Reference in New Issue
Block a user