From f197e09dccdb62b33952a4af5789da21aba1ed3c Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Wed, 23 Sep 2026 21:11:50 +0000 Subject: [PATCH] refactor(dataflows): read company identity and settlement prices through the data layer - the Yahoo vendor owns get_company_profile and get_closes; agent_utils and the graph no longer import yfinance - a test keeps vendor libraries inside dataflows --- tests/test_graph_end_to_end.py | 4 +-- tests/test_instrument_identity.py | 12 ++++---- tests/test_layering.py | 35 +++++++++++++++++++++++ tests/test_memory_log.py | 2 +- tests/test_symbol_normalization_paths.py | 6 ++-- tradingagents/agents/utils/agent_utils.py | 10 +++---- tradingagents/dataflows/y_finance.py | 19 ++++++++++++ tradingagents/graph/trading_graph.py | 22 ++++---------- 8 files changed, 76 insertions(+), 34 deletions(-) create mode 100644 tests/test_layering.py diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 6790afc39..52719b162 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -20,7 +20,7 @@ from pydantic import Field from tradingagents.agents import schemas from tradingagents.agents.analysts import sentiment_analyst from tradingagents.agents.utils import agent_utils -from tradingagents.dataflows import interface, market_data_validator +from tradingagents.dataflows import interface, market_data_validator, y_finance from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.graph import trading_graph @@ -106,7 +106,7 @@ def offline(monkeypatch, tmp_path): lambda *a, **k: called.add("ohlcv") or prices.copy()) monkeypatch.setattr(sentiment_analyst, "fetch_stocktwits_messages", lambda *a, **k: "no posts") monkeypatch.setattr(sentiment_analyst, "fetch_reddit_posts", lambda *a, **k: "no posts") - monkeypatch.setattr(agent_utils.yf, "Ticker", lambda s: type("T", (), {"info": {"longName": "NVIDIA"}})()) + monkeypatch.setattr(y_finance.yf, "Ticker", lambda s: type("T", (), {"info": {"longName": "NVIDIA"}})()) agent_utils.resolve_instrument_identity.cache_clear() return called diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py index 7e5087858..5930f13ed 100644 --- a/tests/test_instrument_identity.py +++ b/tests/test_instrument_identity.py @@ -21,7 +21,7 @@ class ResolveInstrumentIdentityTests(unittest.TestCase): resolve_instrument_identity.cache_clear() def test_resolves_company_metadata_from_yfinance(self): - with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + with patch("tradingagents.dataflows.y_finance.yf.Ticker") as mock: mock.return_value.info = { "longName": "TOTO LTD.", "shortName": "TOTO", @@ -38,26 +38,26 @@ class ResolveInstrumentIdentityTests(unittest.TestCase): self.assertEqual(identity["exchange"], "PNK") def test_falls_back_to_short_name(self): - with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + with patch("tradingagents.dataflows.y_finance.yf.Ticker") as mock: mock.return_value.info = {"shortName": "TOTO", "sector": "Industrials"} identity = resolve_instrument_identity("TOTDY") self.assertEqual(identity["company_name"], "TOTO") def test_skips_placeholder_values(self): - with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + with patch("tradingagents.dataflows.y_finance.yf.Ticker") as mock: mock.return_value.info = {"longName": " ", "sector": "None", "industry": "n/a"} identity = resolve_instrument_identity("TOTDY") self.assertEqual(identity, {}) def test_fails_open_on_exception(self): with patch( - "tradingagents.agents.utils.agent_utils.yf.Ticker", + "tradingagents.dataflows.y_finance.yf.Ticker", side_effect=RuntimeError("rate limited"), ): self.assertEqual(resolve_instrument_identity("TOTDY"), {}) def test_result_is_cached(self): - with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + with patch("tradingagents.dataflows.y_finance.yf.Ticker") as mock: mock.return_value.info = {"longName": "TOTO LTD."} first = resolve_instrument_identity("TOTDY") second = resolve_instrument_identity("TOTDY") @@ -104,7 +104,7 @@ class GetInstrumentContextFromStateTests(unittest.TestCase): def test_fallback_is_network_free_ticker_only(self): # No instrument_context and no yfinance call — must not hit the network. - with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + with patch("tradingagents.dataflows.y_finance.yf.Ticker") as mock: context = get_instrument_context_from_state( {"company_of_interest": "NVDA", "asset_type": "stock"} ) diff --git a/tests/test_layering.py b/tests/test_layering.py new file mode 100644 index 000000000..21206ed44 --- /dev/null +++ b/tests/test_layering.py @@ -0,0 +1,35 @@ +"""Only the data layer imports vendor libraries. + +Vendor calls belong in dataflows, where failures are raised as VendorError +subclasses; a call made elsewhere can report an outage as a fact about the market. +""" + +import ast +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +VENDOR_LIBRARIES = {"yfinance"} + + +def _imports(path: Path) -> set[str]: + names = set() + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if isinstance(node, ast.Import): + names |= {a.name.split(".")[0] for a in node.names} + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + names.add(node.module.split(".")[0]) + return names + + +@pytest.mark.unit +def test_vendor_libraries_are_imported_only_by_the_data_layer(): + data_layer = ROOT / "tradingagents" / "dataflows" + offenders = sorted( + str(path.relative_to(ROOT)) + for package in ("tradingagents", "cli") + for path in (ROOT / package).rglob("*.py") + if data_layer not in path.parents and _imports(path) & VENDOR_LIBRARIES + ) + assert offenders == [] diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 9fef25c99..42dc89c12 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -1043,7 +1043,7 @@ def test_a_longer_window_asks_for_enough_price_history(monkeypatch): days = pd.bdate_range(start, end) return pd.DataFrame({"Close": range(len(days))}, index=days) - monkeypatch.setattr("tradingagents.graph.trading_graph.yf.Ticker", _Ticker) + monkeypatch.setattr("tradingagents.dataflows.y_finance.yf.Ticker", _Ticker) raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY") diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index 1e0bac6bf..4f4fde383 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -8,8 +8,8 @@ hit the right instrument instead of failing/mismatching. import pandas as pd import tradingagents.agents.utils.agent_utils as au +import tradingagents.dataflows.y_finance as y_finance import tradingagents.dataflows.yfinance_news as ynews -import tradingagents.graph.trading_graph as tg from tradingagents.graph.trading_graph import TradingAgentsGraph @@ -24,7 +24,7 @@ def test_identity_lookup_normalizes_symbol(monkeypatch): def info(self): return {"longName": "Gold Futures", "quoteType": "FUTURE"} - monkeypatch.setattr(au.yf, "Ticker", FakeTicker) + monkeypatch.setattr(y_finance.yf, "Ticker", FakeTicker) au.resolve_instrument_identity.cache_clear() identity = au.resolve_instrument_identity("XAUUSD") @@ -45,7 +45,7 @@ def test_fetch_returns_normalizes_symbol(monkeypatch): idx = pd.date_range(start="2025-01-02", periods=len(prices), freq="D") return pd.DataFrame({"Close": prices}, index=idx) - monkeypatch.setattr(tg.yf, "Ticker", FakeTicker) + monkeypatch.setattr(y_finance.yf, "Ticker", FakeTicker) # _fetch_returns does not use ``self``; call unbound to avoid building the graph. raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns( diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 3004d576f..8948d628a 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -3,7 +3,6 @@ import logging from collections.abc import Mapping from typing import Any -import yfinance as yf from langchain_core.messages import HumanMessage, RemoveMessage # Import tools from separate utility files @@ -23,6 +22,7 @@ from tradingagents.agents.utils.news_data_tools import ( ) from tradingagents.agents.utils.prediction_markets_tools import get_prediction_markets from tradingagents.agents.utils.technical_indicators_tools import get_indicators +from tradingagents.dataflows.y_finance import get_company_profile # Public surface: the data tools are imported here so agents and the graph # import them from one place, plus the instrument/language helpers defined below. @@ -106,13 +106,11 @@ def resolve_instrument_identity(ticker: str) -> dict: ticker-only context rather than failing before analysis starts. Cached so the lookup happens at most once per ticker per process. - The symbol is normalized first (e.g. ``XAUUSD`` -> ``GC=F``) so identity - resolves for the same instrument the price path actually fetches (#983). + Identity resolves for the same instrument the price path fetches + (``XAUUSD`` -> ``GC=F``, #983). """ - from tradingagents.dataflows.symbol_utils import normalize_symbol - try: - info = yf.Ticker(normalize_symbol(ticker)).info or {} + info = get_company_profile(ticker) except Exception as exc: # noqa: BLE001 — fail open, never block the run logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc) return {} diff --git a/tradingagents/dataflows/y_finance.py b/tradingagents/dataflows/y_finance.py index a523cede5..dbcec0d40 100644 --- a/tradingagents/dataflows/y_finance.py +++ b/tradingagents/dataflows/y_finance.py @@ -519,3 +519,22 @@ def get_insider_transactions( except Exception as e: raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from e + + +def get_company_profile(ticker: str) -> dict: + """Yahoo's current profile for ``ticker``: name, sector, industry and the like.""" + canonical = normalize_symbol(ticker) + try: + return yf_retry(lambda: yf.Ticker(canonical).info) or {} + except Exception as e: + raise NoMarketDataError(ticker, canonical, f"profile unavailable: {e}") from e + + +def get_closes(symbol: str, start_date: str, end_date: str) -> pd.Series: + """Daily closes from ``start_date`` up to, not including, ``end_date``.""" + canonical = normalize_symbol(symbol) + try: + history = yf_retry(lambda: yf.Ticker(canonical).history(start=start_date, end=end_date)) + except Exception as e: + raise NoMarketDataError(symbol, canonical, f"prices unavailable: {e}") from e + return history["Close"] if "Close" in history else pd.Series(dtype=float) diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8495c32d7..a8c8bef88 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -8,7 +8,6 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any -import yfinance as yf from langgraph.prebuilt import ToolNode # Import the abstract tool methods from agent_utils @@ -31,6 +30,7 @@ from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.memory import TradingMemoryLog from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.utils import get_current_date, safe_ticker_component +from tradingagents.dataflows.y_finance import get_closes from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.llm_clients import create_llm_client from tradingagents.reporting import write_report_tree @@ -302,8 +302,6 @@ class TradingAgentsGraph: the full holding window has not traded (#1169), or the symbol is delisted or unreachable. """ - from tradingagents.dataflows.symbol_utils import normalize_symbol - try: start = datetime.strptime(trade_date, "%Y-%m-%d") # holding_days counts trading days, so ask for the calendar span they @@ -311,11 +309,9 @@ class TradingAgentsGraph: end = start + timedelta(days=round(holding_days * 7 / 5) + 7) end_str = end.strftime("%Y-%m-%d") - # Normalize so the realized-return lookup hits the same instrument - # the analysis priced (e.g. XAUUSD -> GC=F) (#984). The benchmark is - # already a canonical Yahoo symbol from ``_resolve_benchmark``. - stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str) - bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str) + # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984). + stock = get_closes(ticker, trade_date, end_str) + bench = get_closes(benchmark, trade_date, end_str) # Require the full holding window in both series. A rerun before it # has traded leaves the entry pending to retry next run, rather than @@ -323,14 +319,8 @@ class TradingAgentsGraph: if len(stock) <= holding_days or len(bench) <= holding_days: return None, None, None, None - raw = float( - (stock["Close"].iloc[holding_days] - stock["Close"].iloc[0]) - / stock["Close"].iloc[0] - ) - bench_ret = float( - (bench["Close"].iloc[holding_days] - bench["Close"].iloc[0]) - / bench["Close"].iloc[0] - ) + raw = float((stock.iloc[holding_days] - stock.iloc[0]) / stock.iloc[0]) + bench_ret = float((bench.iloc[holding_days] - bench.iloc[0]) / bench.iloc[0]) alpha = raw - bench_ret # The date of the last price bar used is when this outcome became # known — the point-in-time cutoff for injecting the lesson (#1251).