mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
fix(data): reject stale yfinance OHLCV instead of reporting wrong prices
yfinance intermittently returns a year-old partial frame (e.g. June 2025 rows for a June 2026 request) that still has rows and a Close, so it passed the empty-check and silently fed a wrong close price and indicators into the report (#1021). Add a freshness guard that rejects a frame whose latest row is far older than the requested date, on both the raw OHLCV path and the indicator path. It raises the existing NoMarketDataError with a stale-specific detail, so the vendor router's try-next-vendor and single unavailable-signal handling apply unchanged; the sentinel now surfaces that detail so the agent reports the specific reason rather than fabricating a value.
This commit is contained in:
113
tests/test_yfinance_stale_ohlcv_guard.py
Normal file
113
tests/test_yfinance_stale_ohlcv_guard.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Stale OHLCV guard (#1021): a vendor returning a year-old partial frame must
|
||||
be rejected, not fed into the report as if it were current.
|
||||
|
||||
The guard raises NoMarketDataError with a stale-specific detail, so the router's
|
||||
existing try-next-vendor + single-sentinel handling applies and the sentinel
|
||||
surfaces the reason.
|
||||
"""
|
||||
import copy
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import tradingagents.dataflows.config as config_module
|
||||
import tradingagents.dataflows.y_finance as y_finance
|
||||
import tradingagents.default_config as default_config
|
||||
from tradingagents.dataflows import interface
|
||||
from tradingagents.dataflows.config import set_config
|
||||
from tradingagents.dataflows.stockstats_utils import _assert_ohlcv_not_stale
|
||||
from tradingagents.dataflows.symbol_utils import NoMarketDataError
|
||||
|
||||
|
||||
def _frame(date):
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"Date": [pd.Timestamp(date)],
|
||||
"Open": [330.0],
|
||||
"High": [332.0],
|
||||
"Low": [328.0],
|
||||
"Close": [330.58],
|
||||
"Volume": [1_000_000],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardUnitTests(unittest.TestCase):
|
||||
def test_recent_prior_trading_day_is_accepted(self):
|
||||
# 1 day before curr_date — well within the freshness window.
|
||||
_assert_ohlcv_not_stale(_frame("2026-06-10"), "2026-06-11", "CB")
|
||||
|
||||
def test_year_old_row_is_rejected_with_detail(self):
|
||||
with self.assertRaises(NoMarketDataError) as ctx:
|
||||
_assert_ohlcv_not_stale(_frame("2025-06-11"), "2026-06-11", "CB", "CB")
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("2025-06-11", msg)
|
||||
self.assertIn("2026-06-11", msg)
|
||||
self.assertIn("stale", msg)
|
||||
|
||||
def test_empty_frame_is_left_to_caller(self):
|
||||
# Empty is a no-data condition handled elsewhere, not a staleness one.
|
||||
_assert_ohlcv_not_stale(
|
||||
pd.DataFrame(columns=["Date", "Close"]), "2026-06-11", "X"
|
||||
)
|
||||
|
||||
def test_long_holiday_gap_within_threshold_is_accepted(self):
|
||||
_assert_ohlcv_not_stale(_frame("2026-06-02"), "2026-06-11", "X") # 9 days
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardPropagationTests(unittest.TestCase):
|
||||
def test_get_yfin_data_online_raises_on_stale_frame(self):
|
||||
stale = pd.DataFrame(
|
||||
{
|
||||
"Open": [280.0], "High": [286.0], "Low": [278.0],
|
||||
"Close": [284.45], "Volume": [1_000_000],
|
||||
},
|
||||
index=pd.DatetimeIndex([pd.Timestamp("2025-06-11")], name="Date"),
|
||||
)
|
||||
|
||||
class DummyTicker:
|
||||
def __init__(self, symbol):
|
||||
pass
|
||||
|
||||
def history(self, start, end):
|
||||
return stale
|
||||
|
||||
with mock.patch.object(y_finance.yf, "Ticker", DummyTicker), \
|
||||
self.assertRaises(NoMarketDataError):
|
||||
y_finance.get_YFin_data_online("CB", "2026-06-01", "2026-06-11")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class StaleGuardRoutingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def tearDown(self):
|
||||
config_module._config = copy.deepcopy(default_config.DEFAULT_CONFIG)
|
||||
|
||||
def test_router_sentinel_surfaces_stale_reason(self):
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance"}})
|
||||
|
||||
def _stale(symbol, *a, **k):
|
||||
raise NoMarketDataError(
|
||||
symbol, symbol, "latest row is 2025-06-11, 365 days before ... (stale)"
|
||||
)
|
||||
|
||||
with mock.patch.dict(
|
||||
interface.VENDOR_METHODS,
|
||||
{"get_stock_data": {"yfinance": _stale}},
|
||||
clear=False,
|
||||
):
|
||||
out = interface.route_to_vendor(
|
||||
"get_stock_data", "CB", "2026-06-01", "2026-06-11"
|
||||
)
|
||||
self.assertIn("NO_DATA_AVAILABLE", out)
|
||||
self.assertIn("stale", out) # the typed detail is surfaced to the agent
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user