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
This commit is contained in:
Yijia-Xiao
2026-09-24 00:41:56 +00:00
parent b4479b0c70
commit f197e09dcc
8 changed files with 76 additions and 34 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ from pydantic import Field
from tradingagents.agents import schemas from tradingagents.agents import schemas
from tradingagents.agents.analysts import sentiment_analyst from tradingagents.agents.analysts import sentiment_analyst
from tradingagents.agents.utils import agent_utils 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.default_config import DEFAULT_CONFIG
from tradingagents.graph import trading_graph from tradingagents.graph import trading_graph
@@ -106,7 +106,7 @@ def offline(monkeypatch, tmp_path):
lambda *a, **k: called.add("ohlcv") or prices.copy()) 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_stocktwits_messages", lambda *a, **k: "no posts")
monkeypatch.setattr(sentiment_analyst, "fetch_reddit_posts", 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() agent_utils.resolve_instrument_identity.cache_clear()
return called return called
+6 -6
View File
@@ -21,7 +21,7 @@ class ResolveInstrumentIdentityTests(unittest.TestCase):
resolve_instrument_identity.cache_clear() resolve_instrument_identity.cache_clear()
def test_resolves_company_metadata_from_yfinance(self): 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 = { mock.return_value.info = {
"longName": "TOTO LTD.", "longName": "TOTO LTD.",
"shortName": "TOTO", "shortName": "TOTO",
@@ -38,26 +38,26 @@ class ResolveInstrumentIdentityTests(unittest.TestCase):
self.assertEqual(identity["exchange"], "PNK") self.assertEqual(identity["exchange"], "PNK")
def test_falls_back_to_short_name(self): 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"} mock.return_value.info = {"shortName": "TOTO", "sector": "Industrials"}
identity = resolve_instrument_identity("TOTDY") identity = resolve_instrument_identity("TOTDY")
self.assertEqual(identity["company_name"], "TOTO") self.assertEqual(identity["company_name"], "TOTO")
def test_skips_placeholder_values(self): 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"} mock.return_value.info = {"longName": " ", "sector": "None", "industry": "n/a"}
identity = resolve_instrument_identity("TOTDY") identity = resolve_instrument_identity("TOTDY")
self.assertEqual(identity, {}) self.assertEqual(identity, {})
def test_fails_open_on_exception(self): def test_fails_open_on_exception(self):
with patch( with patch(
"tradingagents.agents.utils.agent_utils.yf.Ticker", "tradingagents.dataflows.y_finance.yf.Ticker",
side_effect=RuntimeError("rate limited"), side_effect=RuntimeError("rate limited"),
): ):
self.assertEqual(resolve_instrument_identity("TOTDY"), {}) self.assertEqual(resolve_instrument_identity("TOTDY"), {})
def test_result_is_cached(self): 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."} mock.return_value.info = {"longName": "TOTO LTD."}
first = resolve_instrument_identity("TOTDY") first = resolve_instrument_identity("TOTDY")
second = 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): def test_fallback_is_network_free_ticker_only(self):
# No instrument_context and no yfinance call — must not hit the network. # 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( context = get_instrument_context_from_state(
{"company_of_interest": "NVDA", "asset_type": "stock"} {"company_of_interest": "NVDA", "asset_type": "stock"}
) )
+35
View File
@@ -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 == []
+1 -1
View File
@@ -1043,7 +1043,7 @@ def test_a_longer_window_asks_for_enough_price_history(monkeypatch):
days = pd.bdate_range(start, end) days = pd.bdate_range(start, end)
return pd.DataFrame({"Close": range(len(days))}, index=days) 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") raw, alpha, days, resolved = graph._fetch_returns("NVDA", "2026-06-01", 21, benchmark="SPY")
+3 -3
View File
@@ -8,8 +8,8 @@ hit the right instrument instead of failing/mismatching.
import pandas as pd import pandas as pd
import tradingagents.agents.utils.agent_utils as au 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.dataflows.yfinance_news as ynews
import tradingagents.graph.trading_graph as tg
from tradingagents.graph.trading_graph import TradingAgentsGraph from tradingagents.graph.trading_graph import TradingAgentsGraph
@@ -24,7 +24,7 @@ def test_identity_lookup_normalizes_symbol(monkeypatch):
def info(self): def info(self):
return {"longName": "Gold Futures", "quoteType": "FUTURE"} 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() au.resolve_instrument_identity.cache_clear()
identity = au.resolve_instrument_identity("XAUUSD") 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") idx = pd.date_range(start="2025-01-02", periods=len(prices), freq="D")
return pd.DataFrame({"Close": prices}, index=idx) 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. # _fetch_returns does not use ``self``; call unbound to avoid building the graph.
raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns( raw, alpha, days, resolved = TradingAgentsGraph._fetch_returns(
+4 -6
View File
@@ -3,7 +3,6 @@ import logging
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
import yfinance as yf
from langchain_core.messages import HumanMessage, RemoveMessage from langchain_core.messages import HumanMessage, RemoveMessage
# Import tools from separate utility files # 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.prediction_markets_tools import get_prediction_markets
from tradingagents.agents.utils.technical_indicators_tools import get_indicators 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 # 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. # 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 ticker-only context rather than failing before analysis starts. Cached so
the lookup happens at most once per ticker per process. the lookup happens at most once per ticker per process.
The symbol is normalized first (e.g. ``XAUUSD`` -> ``GC=F``) so identity Identity resolves for the same instrument the price path fetches
resolves for the same instrument the price path actually fetches (#983). (``XAUUSD`` -> ``GC=F``, #983).
""" """
from tradingagents.dataflows.symbol_utils import normalize_symbol
try: 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 except Exception as exc: # noqa: BLE001 — fail open, never block the run
logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc) logger.debug("Could not resolve instrument identity for %s: %s", ticker, exc)
return {} return {}
+19
View File
@@ -519,3 +519,22 @@ def get_insider_transactions(
except Exception as e: except Exception as e:
raise NoMarketDataError(ticker, canonical, f"insider transactions unavailable: {e}") from 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)
+6 -16
View File
@@ -8,7 +8,6 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yfinance as yf
from langgraph.prebuilt import ToolNode from langgraph.prebuilt import ToolNode
# Import the abstract tool methods from agent_utils # 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.agents.utils.memory import TradingMemoryLog
from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.config import run_config, set_config
from tradingagents.dataflows.utils import get_current_date, safe_ticker_component 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.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients import create_llm_client from tradingagents.llm_clients import create_llm_client
from tradingagents.reporting import write_report_tree 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 the full holding window has not traded (#1169), or the symbol is delisted
or unreachable. or unreachable.
""" """
from tradingagents.dataflows.symbol_utils import normalize_symbol
try: try:
start = datetime.strptime(trade_date, "%Y-%m-%d") start = datetime.strptime(trade_date, "%Y-%m-%d")
# holding_days counts trading days, so ask for the calendar span they # 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 = start + timedelta(days=round(holding_days * 7 / 5) + 7)
end_str = end.strftime("%Y-%m-%d") end_str = end.strftime("%Y-%m-%d")
# Normalize so the realized-return lookup hits the same instrument # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984).
# the analysis priced (e.g. XAUUSD -> GC=F) (#984). The benchmark is stock = get_closes(ticker, trade_date, end_str)
# already a canonical Yahoo symbol from ``_resolve_benchmark``. bench = get_closes(benchmark, trade_date, end_str)
stock = yf.Ticker(normalize_symbol(ticker)).history(start=trade_date, end=end_str)
bench = yf.Ticker(benchmark).history(start=trade_date, end=end_str)
# Require the full holding window in both series. A rerun before it # Require the full holding window in both series. A rerun before it
# has traded leaves the entry pending to retry next run, rather than # 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: if len(stock) <= holding_days or len(bench) <= holding_days:
return None, None, None, None return None, None, None, None
raw = float( raw = float((stock.iloc[holding_days] - stock.iloc[0]) / stock.iloc[0])
(stock["Close"].iloc[holding_days] - stock["Close"].iloc[0]) bench_ret = float((bench.iloc[holding_days] - bench.iloc[0]) / bench.iloc[0])
/ stock["Close"].iloc[0]
)
bench_ret = float(
(bench["Close"].iloc[holding_days] - bench["Close"].iloc[0])
/ bench["Close"].iloc[0]
)
alpha = raw - bench_ret alpha = raw - bench_ret
# The date of the last price bar used is when this outcome became # The date of the last price bar used is when this outcome became
# known — the point-in-time cutoff for injecting the lesson (#1251). # known — the point-in-time cutoff for injecting the lesson (#1251).