From d7b40a2a5ce67cc6166a63e9b4c4c0df09296ff9 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 30 May 2026 23:56:32 +0000 Subject: [PATCH] fix(graph): resolve instrument identity to stop wrong-company hallucination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents had no ground-truth ticker→company mapping, so the market analyst could pattern-match a price chart to the wrong company (e.g. TOTDY read as "TotalEnergies"), and every downstream agent inherited the bad framing. Resolve identity once at run start via a cached, fail-open yfinance lookup and inject company/sector/exchange into the shared instrument context that all twelve agents consume, with an explicit do-not-substitute instruction. Resolution runs on both the propagate() and CLI entry points. Also replaces the bare "Continue" message-clear placeholder, which some OpenAI-compatible providers interpreted as the user task, with a context-anchored placeholder carrying the resolved identity and date. #814 #888 --- cli/main.py | 9 +- tests/test_instrument_identity.py | 170 ++++++++++++++++++ .../agents/analysts/fundamentals_analyst.py | 4 +- .../agents/analysts/market_analyst.py | 7 +- tradingagents/agents/analysts/news_analyst.py | 6 +- .../agents/analysts/sentiment_analyst.py | 4 +- .../agents/managers/portfolio_manager.py | 4 +- .../agents/managers/research_manager.py | 4 +- .../agents/researchers/bear_researcher.py | 7 +- .../agents/researchers/bull_researcher.py | 7 +- .../agents/risk_mgmt/aggressive_debator.py | 7 +- .../agents/risk_mgmt/conservative_debator.py | 7 +- .../agents/risk_mgmt/neutral_debator.py | 7 +- tradingagents/agents/trader/trader.py | 5 +- tradingagents/agents/utils/agent_states.py | 1 + tradingagents/agents/utils/agent_utils.py | 150 ++++++++++++++-- tradingagents/graph/propagation.py | 11 +- tradingagents/graph/trading_graph.py | 24 ++- 18 files changed, 390 insertions(+), 44 deletions(-) create mode 100644 tests/test_instrument_identity.py diff --git a/cli/main.py b/cli/main.py index 17c284c2a..d6f2b72ce 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1098,11 +1098,18 @@ def run_analysis(checkpoint: bool = False): ) update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) - # Initialize state and get graph args with callbacks + # Initialize state and get graph args with callbacks. + # Resolve the instrument identity once here so all agents anchor to + # the real company (#814); the CLI builds state directly rather than + # going through propagate(), so this must happen on the CLI path too. + instrument_context = graph.resolve_instrument_context( + selections["ticker"], selections["asset_type"] + ) init_agent_state = graph.propagator.create_initial_state( selections["ticker"], selections["analysis_date"], asset_type=selections["asset_type"], + instrument_context=instrument_context, ) # Pass callbacks to graph config for tool execution tracking # (LLM tracking is handled separately via LLM constructor) diff --git a/tests/test_instrument_identity.py b/tests/test_instrument_identity.py new file mode 100644 index 000000000..7e5087858 --- /dev/null +++ b/tests/test_instrument_identity.py @@ -0,0 +1,170 @@ +"""Tests for deterministic instrument-identity resolution (#814) and the +context-anchored message placeholder (#888).""" + +import unittest +from unittest.mock import patch + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage + +from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + create_msg_delete, + get_instrument_context_from_state, + resolve_instrument_identity, +) + + +@pytest.mark.unit +class ResolveInstrumentIdentityTests(unittest.TestCase): + def setUp(self): + resolve_instrument_identity.cache_clear() + + def test_resolves_company_metadata_from_yfinance(self): + with patch("tradingagents.agents.utils.agent_utils.yf.Ticker") as mock: + mock.return_value.info = { + "longName": "TOTO LTD.", + "shortName": "TOTO", + "sector": "Industrials", + "industry": "Building Products & Equipment", + "exchange": "PNK", + "quoteType": "EQUITY", + } + identity = resolve_instrument_identity("totdy") + mock.assert_called_once_with("TOTDY") + self.assertEqual(identity["company_name"], "TOTO LTD.") + self.assertEqual(identity["sector"], "Industrials") + self.assertEqual(identity["industry"], "Building Products & Equipment") + self.assertEqual(identity["exchange"], "PNK") + + def test_falls_back_to_short_name(self): + with patch("tradingagents.agents.utils.agent_utils.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: + 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", + 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: + mock.return_value.info = {"longName": "TOTO LTD."} + first = resolve_instrument_identity("TOTDY") + second = resolve_instrument_identity("TOTDY") + mock.assert_called_once() # second call served from cache + self.assertEqual(first, second) + + +@pytest.mark.unit +class BuildInstrumentContextTests(unittest.TestCase): + def test_mentions_exact_symbol_without_identity(self): + context = build_instrument_context("7203.T") + self.assertIn("7203.T", context) + self.assertIn("exchange suffix", context) + self.assertNotIn("Resolved identity", context) + + def test_injects_resolved_identity(self): + context = build_instrument_context( + "TOTDY", "stock", + { + "company_name": "TOTO LTD.", + "sector": "Industrials", + "industry": "Building Products & Equipment", + "exchange": "PNK", + }, + ) + self.assertIn("Company: TOTO LTD.", context) + self.assertIn("Industrials / Building Products & Equipment", context) + self.assertIn("Exchange: PNK", context) + self.assertIn("Do not substitute a different company", context) + + def test_crypto_uses_name_label_and_keeps_hint(self): + context = build_instrument_context( + "BTC-USD", "crypto", {"company_name": "Bitcoin USD"} + ) + self.assertIn("Name: Bitcoin USD", context) + self.assertIn("crypto asset rather than a company", context) + + +@pytest.mark.unit +class GetInstrumentContextFromStateTests(unittest.TestCase): + def test_prefers_precomputed_context(self): + state = {"company_of_interest": "TOTDY", "instrument_context": "PRECOMPUTED"} + self.assertEqual(get_instrument_context_from_state(state), "PRECOMPUTED") + + 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: + context = get_instrument_context_from_state( + {"company_of_interest": "NVDA", "asset_type": "stock"} + ) + mock.assert_not_called() + self.assertIn("NVDA", context) + + def test_fallback_respects_asset_type(self): + context = get_instrument_context_from_state( + {"company_of_interest": "BTC-USD", "asset_type": "crypto"} + ) + self.assertIn("crypto asset", context) + + +@pytest.mark.unit +class ContextAnchoredPlaceholderTests(unittest.TestCase): + """#888 — the message-clear placeholder must not be a bare 'Continue'.""" + + def _run(self, state_extra): + state = { + "messages": [ + HumanMessage(content="old", id="h1"), + AIMessage(content="reply", id="a1"), + ], + **state_extra, + } + return create_msg_delete()(state) + + def test_placeholder_is_not_bare_continue(self): + result = self._run( + {"company_of_interest": "EC", "asset_type": "stock", "trade_date": "2026-05-28"} + ) + placeholder = result["messages"][-1] + self.assertIsInstance(placeholder, HumanMessage) + self.assertNotEqual(placeholder.content.strip(), "Continue") + + def test_placeholder_carries_resolved_identity(self): + result = self._run( + { + "company_of_interest": "EC", + "instrument_context": "The instrument to analyze is `EC`. Resolved identity: Company: Ecopetrol.", + "trade_date": "2026-05-28", + } + ) + content = result["messages"][-1].content + self.assertIn("Ecopetrol", content) + self.assertIn("2026-05-28", content) + + def test_old_messages_are_removed(self): + result = self._run({"company_of_interest": "EC", "trade_date": "2026-05-28"}) + removals = [m for m in result["messages"] if isinstance(m, RemoveMessage)] + humans = [m for m in result["messages"] if isinstance(m, HumanMessage)] + self.assertEqual(len(removals), 2) + self.assertEqual(len(humans), 1) + + def test_safe_defaults_when_state_minimal(self): + result = create_msg_delete()({"messages": [], "company_of_interest": "EC"}) + placeholder = result["messages"][-1] + self.assertNotEqual(placeholder.content.strip(), "Continue") + self.assertIn("EC", placeholder.content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tradingagents/agents/analysts/fundamentals_analyst.py b/tradingagents/agents/analysts/fundamentals_analyst.py index 6aa49cf3b..b2ea3bcfb 100644 --- a/tradingagents/agents/analysts/fundamentals_analyst.py +++ b/tradingagents/agents/analysts/fundamentals_analyst.py @@ -1,6 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_balance_sheet, get_cashflow, get_fundamentals, @@ -14,7 +14,7 @@ from tradingagents.dataflows.config import get_config def create_fundamentals_analyst(llm): def fundamentals_analyst_node(state): current_date = state["trade_date"] - instrument_context = build_instrument_context(state["company_of_interest"]) + instrument_context = get_instrument_context_from_state(state) tools = [ get_fundamentals, diff --git a/tradingagents/agents/analysts/market_analyst.py b/tradingagents/agents/analysts/market_analyst.py index 01020962e..af805dbfc 100644 --- a/tradingagents/agents/analysts/market_analyst.py +++ b/tradingagents/agents/analysts/market_analyst.py @@ -1,6 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_indicators, get_language_instruction, get_stock_data, @@ -12,10 +12,7 @@ def create_market_analyst(llm): def market_analyst_node(state): current_date = state["trade_date"] - asset_type = state.get("asset_type", "stock") - instrument_context = build_instrument_context( - state["company_of_interest"], asset_type - ) + instrument_context = get_instrument_context_from_state(state) tools = [ get_stock_data, diff --git a/tradingagents/agents/analysts/news_analyst.py b/tradingagents/agents/analysts/news_analyst.py index 90917d168..64825058e 100644 --- a/tradingagents/agents/analysts/news_analyst.py +++ b/tradingagents/agents/analysts/news_analyst.py @@ -1,6 +1,6 @@ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_global_news, get_language_instruction, get_news, @@ -13,9 +13,7 @@ def create_news_analyst(llm): current_date = state["trade_date"] asset_type = state.get("asset_type", "stock") asset_label = "company" if asset_type == "stock" else "asset" - instrument_context = build_instrument_context( - state["company_of_interest"], asset_type - ) + instrument_context = get_instrument_context_from_state(state) tools = [ get_news, diff --git a/tradingagents/agents/analysts/sentiment_analyst.py b/tradingagents/agents/analysts/sentiment_analyst.py index e1e4ee4f4..ad9c5d72c 100644 --- a/tradingagents/agents/analysts/sentiment_analyst.py +++ b/tradingagents/agents/analysts/sentiment_analyst.py @@ -23,7 +23,7 @@ from datetime import datetime, timedelta from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_language_instruction, get_news, ) @@ -47,7 +47,7 @@ def create_sentiment_analyst(llm): ticker = state["company_of_interest"] end_date = state["trade_date"] start_date = _seven_days_back(end_date) - instrument_context = build_instrument_context(ticker) + instrument_context = get_instrument_context_from_state(state) # Pre-fetch all three sources. Each fetcher degrades gracefully and # returns a string (no exceptions surface from here), so the LLM diff --git a/tradingagents/agents/managers/portfolio_manager.py b/tradingagents/agents/managers/portfolio_manager.py index 0e7c18234..36cdef549 100644 --- a/tradingagents/agents/managers/portfolio_manager.py +++ b/tradingagents/agents/managers/portfolio_manager.py @@ -12,7 +12,7 @@ from __future__ import annotations from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_language_instruction, ) from tradingagents.agents.utils.structured import ( @@ -25,7 +25,7 @@ def create_portfolio_manager(llm): structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager") def portfolio_manager_node(state) -> dict: - instrument_context = build_instrument_context(state["company_of_interest"]) + instrument_context = get_instrument_context_from_state(state) history = state["risk_debate_state"]["history"] risk_debate_state = state["risk_debate_state"] diff --git a/tradingagents/agents/managers/research_manager.py b/tradingagents/agents/managers/research_manager.py index 924b36b4d..7d39ed0b7 100644 --- a/tradingagents/agents/managers/research_manager.py +++ b/tradingagents/agents/managers/research_manager.py @@ -4,7 +4,7 @@ from __future__ import annotations from tradingagents.agents.schemas import ResearchPlan, render_research_plan from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_language_instruction, ) from tradingagents.agents.utils.structured import ( @@ -17,7 +17,7 @@ def create_research_manager(llm): structured_llm = bind_structured(llm, ResearchPlan, "Research Manager") def research_manager_node(state) -> dict: - instrument_context = build_instrument_context(state["company_of_interest"]) + instrument_context = get_instrument_context_from_state(state) history = state["investment_debate_state"].get("history", "") investment_debate_state = state["investment_debate_state"] diff --git a/tradingagents/agents/researchers/bear_researcher.py b/tradingagents/agents/researchers/bear_researcher.py index 0f7fe5a3c..860ae4be5 100644 --- a/tradingagents/agents/researchers/bear_researcher.py +++ b/tradingagents/agents/researchers/bear_researcher.py @@ -1,4 +1,7 @@ -from tradingagents.agents.utils.agent_utils import get_language_instruction +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) def create_bear_researcher(llm): @@ -12,6 +15,7 @@ def create_bear_researcher(llm): sentiment_report = state["sentiment_report"] news_report = state["news_report"] fundamentals_report = state["fundamentals_report"] + instrument_context = get_instrument_context_from_state(state) asset_type = state.get("asset_type", "stock") target_label = "stock" if asset_type == "stock" else "asset" fundamentals_label = ( @@ -32,6 +36,7 @@ Key points to focus on: Resources available: +{instrument_context} Market research report: {market_research_report} Social media sentiment report: {sentiment_report} Latest world affairs news: {news_report} diff --git a/tradingagents/agents/researchers/bull_researcher.py b/tradingagents/agents/researchers/bull_researcher.py index b8eca7509..5f939ed72 100644 --- a/tradingagents/agents/researchers/bull_researcher.py +++ b/tradingagents/agents/researchers/bull_researcher.py @@ -1,4 +1,7 @@ -from tradingagents.agents.utils.agent_utils import get_language_instruction +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) def create_bull_researcher(llm): @@ -12,6 +15,7 @@ def create_bull_researcher(llm): sentiment_report = state["sentiment_report"] news_report = state["news_report"] fundamentals_report = state["fundamentals_report"] + instrument_context = get_instrument_context_from_state(state) asset_type = state.get("asset_type", "stock") target_label = "stock" if asset_type == "stock" else "asset" fundamentals_label = ( @@ -30,6 +34,7 @@ Key points to focus on: - Engagement: Present your argument in a conversational style, engaging directly with the bear analyst's points and debating effectively rather than just listing data. Resources available: +{instrument_context} Market research report: {market_research_report} Social media sentiment report: {sentiment_report} Latest world affairs news: {news_report} diff --git a/tradingagents/agents/risk_mgmt/aggressive_debator.py b/tradingagents/agents/risk_mgmt/aggressive_debator.py index 212e73d6e..3fd0e124a 100644 --- a/tradingagents/agents/risk_mgmt/aggressive_debator.py +++ b/tradingagents/agents/risk_mgmt/aggressive_debator.py @@ -1,4 +1,7 @@ -from tradingagents.agents.utils.agent_utils import get_language_instruction +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) def create_aggressive_debator(llm): @@ -14,6 +17,7 @@ def create_aggressive_debator(llm): sentiment_report = state["sentiment_report"] news_report = state["news_report"] fundamentals_report = state["fundamentals_report"] + instrument_context = get_instrument_context_from_state(state) trader_decision = state["trader_investment_plan"] @@ -23,6 +27,7 @@ def create_aggressive_debator(llm): Your task is to create a compelling case for the trader's decision by questioning and critiquing the conservative and neutral stances to demonstrate why your high-reward perspective offers the best path forward. Incorporate insights from the following sources into your arguments: +{instrument_context} Market Research Report: {market_research_report} Social Media Sentiment Report: {sentiment_report} Latest World Affairs Report: {news_report} diff --git a/tradingagents/agents/risk_mgmt/conservative_debator.py b/tradingagents/agents/risk_mgmt/conservative_debator.py index a7f7342fa..b84add0c3 100644 --- a/tradingagents/agents/risk_mgmt/conservative_debator.py +++ b/tradingagents/agents/risk_mgmt/conservative_debator.py @@ -1,4 +1,7 @@ -from tradingagents.agents.utils.agent_utils import get_language_instruction +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) def create_conservative_debator(llm): @@ -14,6 +17,7 @@ def create_conservative_debator(llm): sentiment_report = state["sentiment_report"] news_report = state["news_report"] fundamentals_report = state["fundamentals_report"] + instrument_context = get_instrument_context_from_state(state) trader_decision = state["trader_investment_plan"] @@ -23,6 +27,7 @@ def create_conservative_debator(llm): Your task is to actively counter the arguments of the Aggressive and Neutral Analysts, highlighting where their views may overlook potential threats or fail to prioritize sustainability. Respond directly to their points, drawing from the following data sources to build a convincing case for a low-risk approach adjustment to the trader's decision: +{instrument_context} Market Research Report: {market_research_report} Social Media Sentiment Report: {sentiment_report} Latest World Affairs Report: {news_report} diff --git a/tradingagents/agents/risk_mgmt/neutral_debator.py b/tradingagents/agents/risk_mgmt/neutral_debator.py index 73b306078..b28ade25b 100644 --- a/tradingagents/agents/risk_mgmt/neutral_debator.py +++ b/tradingagents/agents/risk_mgmt/neutral_debator.py @@ -1,4 +1,7 @@ -from tradingagents.agents.utils.agent_utils import get_language_instruction +from tradingagents.agents.utils.agent_utils import ( + get_instrument_context_from_state, + get_language_instruction, +) def create_neutral_debator(llm): @@ -14,6 +17,7 @@ def create_neutral_debator(llm): sentiment_report = state["sentiment_report"] news_report = state["news_report"] fundamentals_report = state["fundamentals_report"] + instrument_context = get_instrument_context_from_state(state) trader_decision = state["trader_investment_plan"] @@ -23,6 +27,7 @@ def create_neutral_debator(llm): Your task is to challenge both the Aggressive and Conservative Analysts, pointing out where each perspective may be overly optimistic or overly cautious. Use insights from the following data sources to support a moderate, sustainable strategy to adjust the trader's decision: +{instrument_context} Market Research Report: {market_research_report} Social Media Sentiment Report: {sentiment_report} Latest World Affairs Report: {news_report} diff --git a/tradingagents/agents/trader/trader.py b/tradingagents/agents/trader/trader.py index b5aacb67a..403a6a57d 100644 --- a/tradingagents/agents/trader/trader.py +++ b/tradingagents/agents/trader/trader.py @@ -8,7 +8,7 @@ from langchain_core.messages import AIMessage from tradingagents.agents.schemas import TraderProposal, render_trader_proposal from tradingagents.agents.utils.agent_utils import ( - build_instrument_context, + get_instrument_context_from_state, get_language_instruction, ) from tradingagents.agents.utils.structured import ( @@ -22,8 +22,7 @@ def create_trader(llm): def trader_node(state, name): company_name = state["company_of_interest"] - asset_type = state.get("asset_type", "stock") - instrument_context = build_instrument_context(company_name, asset_type) + instrument_context = get_instrument_context_from_state(state) investment_plan = state["investment_plan"] messages = [ diff --git a/tradingagents/agents/utils/agent_states.py b/tradingagents/agents/utils/agent_states.py index 3ce0ef3ca..cce73c79d 100644 --- a/tradingagents/agents/utils/agent_states.py +++ b/tradingagents/agents/utils/agent_states.py @@ -46,6 +46,7 @@ class RiskDebateState(TypedDict): class AgentState(MessagesState): company_of_interest: Annotated[str, "Company that we are interested in trading"] asset_type: Annotated[str, "Asset type under analysis such as stock or crypto"] + instrument_context: Annotated[str, "Deterministic ticker identity resolved at run start"] trade_date: Annotated[str, "What date we are trading at"] sender: Annotated[str, "Agent that sent this message"] diff --git a/tradingagents/agents/utils/agent_utils.py b/tradingagents/agents/utils/agent_utils.py index 3a729b4ba..f137bd4d4 100644 --- a/tradingagents/agents/utils/agent_utils.py +++ b/tradingagents/agents/utils/agent_utils.py @@ -1,3 +1,8 @@ +import functools +import logging +from typing import Any, Mapping, Optional + +import yfinance as yf from langchain_core.messages import HumanMessage, RemoveMessage # Import tools from separate utility files @@ -19,6 +24,8 @@ from tradingagents.agents.utils.news_data_tools import ( get_global_news ) +logger = logging.getLogger(__name__) + def get_language_instruction() -> str: """Return a prompt instruction for the configured output language. @@ -36,32 +43,145 @@ def get_language_instruction() -> str: return f" Write your entire response in {lang}." -def build_instrument_context(ticker: str, asset_type: str = "stock") -> str: - """Describe the exact instrument so agents preserve exchange-qualified tickers.""" - instrument_label = "asset" if asset_type == "crypto" else "instrument" - extra_hint = ( - " Treat it as a crypto asset rather than a company, and do not assume company fundamentals are available." - if asset_type == "crypto" - else "" +def _clean_identity_value(value: Any) -> Optional[str]: + """Return a trimmed string, or None for empty / placeholder-ish values.""" + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned or cleaned.lower() in {"none", "n/a", "nan", "null"}: + return None + return cleaned + + +@functools.lru_cache(maxsize=256) +def resolve_instrument_identity(ticker: str) -> dict: + """Resolve deterministic identity metadata (company name, sector, …) for a ticker. + + This exists to stop the pipeline from hallucinating a *different* company + when a chart pattern suggests a different industry than the real one + (#814): without a ground-truth name, the market analyst would pattern-match + the price action to a narrative and invent an identity that then cascaded + through every downstream agent. + + Best-effort by design: if yfinance is unavailable, rate-limited, or doesn't + recognise the ticker, we return ``{}`` and the caller falls back to + ticker-only context rather than failing before analysis starts. Cached so + the lookup happens at most once per ticker per process. + """ + try: + info = yf.Ticker(ticker.upper()).info or {} + 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 {} + + identity: dict[str, str] = {} + company_name = _clean_identity_value(info.get("longName")) or _clean_identity_value( + info.get("shortName") ) - return ( + if company_name: + identity["company_name"] = company_name + for source_key, target_key in ( + ("sector", "sector"), + ("industry", "industry"), + ("exchange", "exchange"), + ("quoteType", "quote_type"), + ): + value = _clean_identity_value(info.get(source_key)) + if value: + identity[target_key] = value + return identity + + +def build_instrument_context( + ticker: str, + asset_type: str = "stock", + identity: Optional[Mapping[str, str]] = None, +) -> str: + """Describe the exact instrument so agents preserve identity and ticker. + + When ``identity`` is provided (resolved deterministically via + :func:`resolve_instrument_identity`), the company name and business + classification are injected so agents anchor to the real company rather + than pattern-matching the price chart to a wrong one (#814). + """ + is_crypto = asset_type == "crypto" + instrument_label = "asset" if is_crypto else "instrument" + context = ( f"The {instrument_label} to analyze is `{ticker}`. " "Use this exact ticker in every tool call, report, and recommendation, " "preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)." - + extra_hint ) + details = [] + if identity: + name = identity.get("company_name") or identity.get("name") + if name: + details.append(f"{'Name' if is_crypto else 'Company'}: {name}") + sector, industry = identity.get("sector"), identity.get("industry") + if sector and industry: + details.append(f"Business classification: {sector} / {industry}") + elif sector: + details.append(f"Sector: {sector}") + elif industry: + details.append(f"Industry: {industry}") + if identity.get("exchange"): + details.append(f"Exchange: {identity['exchange']}") + + if details: + context += ( + f" Resolved identity: {'; '.join(details)}. " + "Do not substitute a different company or ticker unless a tool " + "result explicitly disproves this resolved identity." + ) + + if is_crypto: + context += ( + " Treat it as a crypto asset rather than a company, and do not " + "assume company fundamentals are available." + ) + return context + + +def get_instrument_context_from_state(state: Mapping[str, Any]) -> str: + """Return the instrument context for the current run. + + Prefers the identity-resolved context computed once at run start and + stored on the state (see ``TradingAgentsGraph.resolve_instrument_context``). + Falls back to a ticker-only context — with no network lookup — when the + state was constructed without it (bare programmatic states, tests), so a + consumer is never forced to make a yfinance call mid-graph. + """ + context = state.get("instrument_context") + if isinstance(context, str) and context.strip(): + return context + return build_instrument_context( + str(state["company_of_interest"]), + state.get("asset_type", "stock"), + ) + + def create_msg_delete(): def delete_messages(state): - """Clear messages and add placeholder for Anthropic compatibility""" - messages = state["messages"] + """Clear messages and add a context-anchored placeholder. - # Remove all messages + The placeholder must not be a bare ``"Continue"``: some + OpenAI-compatible providers interpret that literally as the user task + and produce output about the word "continue" instead of analysing the + instrument (#888). Anchoring it to the resolved instrument context and + date keeps the next analyst on-task even if the provider treats the + placeholder as a standalone request. + """ + messages = state["messages"] removal_operations = [RemoveMessage(id=m.id) for m in messages] - # Add a minimal placeholder message - placeholder = HumanMessage(content="Continue") - + instrument_context = get_instrument_context_from_state(state) + trade_date = state.get("trade_date", "the requested date") + placeholder = HumanMessage( + content=( + f"Proceed with your assigned analysis for this workflow. " + f"{instrument_context} The analysis date is {trade_date}." + ) + ) return {"messages": removal_operations + [placeholder]} return delete_messages diff --git a/tradingagents/graph/propagation.py b/tradingagents/graph/propagation.py index b7736139e..edd93f55e 100644 --- a/tradingagents/graph/propagation.py +++ b/tradingagents/graph/propagation.py @@ -21,12 +21,21 @@ class Propagator: trade_date: str, asset_type: str = "stock", past_context: str = "", + instrument_context: str = "", ) -> Dict[str, Any]: - """Create the initial state for the agent graph.""" + """Create the initial state for the agent graph. + + ``instrument_context`` is the deterministic ticker-identity string + resolved once at run start (see + ``TradingAgentsGraph.resolve_instrument_context``). When empty, agents + fall back to ticker-only context via + ``get_instrument_context_from_state``. + """ return { "messages": [("human", company_name)], "company_of_interest": company_name, "asset_type": asset_type, + "instrument_context": instrument_context, "trade_date": str(trade_date), "past_context": past_context, "investment_debate_state": InvestDebateState( diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index e3d1f125b..fc9a539f4 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -28,6 +28,8 @@ from tradingagents.dataflows.config import set_config # Import the new abstract tool methods from agent_utils from tradingagents.agents.utils.agent_utils import ( + build_instrument_context, + resolve_instrument_identity, get_stock_data, get_indicators, get_fundamentals, @@ -292,6 +294,18 @@ class TradingAgentsGraph: if updates: self.memory_log.batch_update_with_outcomes(updates) + def resolve_instrument_context(self, ticker: str, asset_type: str = "stock") -> str: + """Resolve ticker identity once and return the full instrument context. + + Deterministic yfinance lookup (cached, fail-open) injected into a + context string so every agent anchors to the real company instead of + hallucinating one from the price chart (#814). Both the propagate() + path and the CLI call this so the resolved identity reaches the whole + graph regardless of entry point. + """ + identity = resolve_instrument_identity(ticker) + return build_instrument_context(ticker, asset_type, identity) + def propagate(self, company_name, trade_date, asset_type: str = "stock"): """Run the trading agents graph for a company on a specific date. @@ -335,10 +349,16 @@ class TradingAgentsGraph: def _run_graph(self, company_name, trade_date, asset_type: str = "stock"): """Execute the graph and write the resulting state to disk and memory log.""" - # Initialize state — inject memory log context for PM. + # Initialize state — inject memory log context for PM and the + # deterministically resolved instrument identity for all agents. past_context = self.memory_log.get_past_context(company_name) + instrument_context = self.resolve_instrument_context(company_name, asset_type) init_agent_state = self.propagator.create_initial_state( - company_name, trade_date, asset_type=asset_type, past_context=past_context + company_name, + trade_date, + asset_type=asset_type, + past_context=past_context, + instrument_context=instrument_context, ) args = self.propagator.get_graph_args()