fix(graph): resolve instrument identity to stop wrong-company hallucination

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
This commit is contained in:
Yijia-Xiao
2026-05-30 23:56:32 +00:00
parent 61522e103e
commit d7b40a2a5c
18 changed files with 390 additions and 44 deletions

View File

@@ -1098,11 +1098,18 @@ def run_analysis(checkpoint: bool = False):
) )
update_display(layout, spinner_text, stats_handler=stats_handler, start_time=start_time) 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( init_agent_state = graph.propagator.create_initial_state(
selections["ticker"], selections["ticker"],
selections["analysis_date"], selections["analysis_date"],
asset_type=selections["asset_type"], asset_type=selections["asset_type"],
instrument_context=instrument_context,
) )
# Pass callbacks to graph config for tool execution tracking # Pass callbacks to graph config for tool execution tracking
# (LLM tracking is handled separately via LLM constructor) # (LLM tracking is handled separately via LLM constructor)

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_balance_sheet, get_balance_sheet,
get_cashflow, get_cashflow,
get_fundamentals, get_fundamentals,
@@ -14,7 +14,7 @@ from tradingagents.dataflows.config import get_config
def create_fundamentals_analyst(llm): def create_fundamentals_analyst(llm):
def fundamentals_analyst_node(state): def fundamentals_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
instrument_context = build_instrument_context(state["company_of_interest"]) instrument_context = get_instrument_context_from_state(state)
tools = [ tools = [
get_fundamentals, get_fundamentals,

View File

@@ -1,6 +1,6 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_indicators, get_indicators,
get_language_instruction, get_language_instruction,
get_stock_data, get_stock_data,
@@ -12,10 +12,7 @@ def create_market_analyst(llm):
def market_analyst_node(state): def market_analyst_node(state):
current_date = state["trade_date"] current_date = state["trade_date"]
asset_type = state.get("asset_type", "stock") instrument_context = get_instrument_context_from_state(state)
instrument_context = build_instrument_context(
state["company_of_interest"], asset_type
)
tools = [ tools = [
get_stock_data, get_stock_data,

View File

@@ -1,6 +1,6 @@
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_global_news, get_global_news,
get_language_instruction, get_language_instruction,
get_news, get_news,
@@ -13,9 +13,7 @@ def create_news_analyst(llm):
current_date = state["trade_date"] current_date = state["trade_date"]
asset_type = state.get("asset_type", "stock") asset_type = state.get("asset_type", "stock")
asset_label = "company" if asset_type == "stock" else "asset" asset_label = "company" if asset_type == "stock" else "asset"
instrument_context = build_instrument_context( instrument_context = get_instrument_context_from_state(state)
state["company_of_interest"], asset_type
)
tools = [ tools = [
get_news, get_news,

View File

@@ -23,7 +23,7 @@ from datetime import datetime, timedelta
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
get_news, get_news,
) )
@@ -47,7 +47,7 @@ def create_sentiment_analyst(llm):
ticker = state["company_of_interest"] ticker = state["company_of_interest"]
end_date = state["trade_date"] end_date = state["trade_date"]
start_date = _seven_days_back(end_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 # Pre-fetch all three sources. Each fetcher degrades gracefully and
# returns a string (no exceptions surface from here), so the LLM # returns a string (no exceptions surface from here), so the LLM

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
@@ -25,7 +25,7 @@ def create_portfolio_manager(llm):
structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager") structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager")
def portfolio_manager_node(state) -> dict: 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"] history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"] risk_debate_state = state["risk_debate_state"]

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from tradingagents.agents.schemas import ResearchPlan, render_research_plan from tradingagents.agents.schemas import ResearchPlan, render_research_plan
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
@@ -17,7 +17,7 @@ def create_research_manager(llm):
structured_llm = bind_structured(llm, ResearchPlan, "Research Manager") structured_llm = bind_structured(llm, ResearchPlan, "Research Manager")
def research_manager_node(state) -> dict: 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", "") history = state["investment_debate_state"].get("history", "")
investment_debate_state = state["investment_debate_state"] investment_debate_state = state["investment_debate_state"]

View File

@@ -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): def create_bear_researcher(llm):
@@ -12,6 +15,7 @@ def create_bear_researcher(llm):
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock") asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset" target_label = "stock" if asset_type == "stock" else "asset"
fundamentals_label = ( fundamentals_label = (
@@ -32,6 +36,7 @@ Key points to focus on:
Resources available: Resources available:
{instrument_context}
Market research report: {market_research_report} Market research report: {market_research_report}
Social media sentiment report: {sentiment_report} Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report} Latest world affairs news: {news_report}

View File

@@ -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): def create_bull_researcher(llm):
@@ -12,6 +15,7 @@ def create_bull_researcher(llm):
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
asset_type = state.get("asset_type", "stock") asset_type = state.get("asset_type", "stock")
target_label = "stock" if asset_type == "stock" else "asset" target_label = "stock" if asset_type == "stock" else "asset"
fundamentals_label = ( 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. - 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: Resources available:
{instrument_context}
Market research report: {market_research_report} Market research report: {market_research_report}
Social media sentiment report: {sentiment_report} Social media sentiment report: {sentiment_report}
Latest world affairs news: {news_report} Latest world affairs news: {news_report}

View File

@@ -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): def create_aggressive_debator(llm):
@@ -14,6 +17,7 @@ def create_aggressive_debator(llm):
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
trader_decision = state["trader_investment_plan"] 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: 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} Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report} Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report} Latest World Affairs Report: {news_report}

View File

@@ -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): def create_conservative_debator(llm):
@@ -14,6 +17,7 @@ def create_conservative_debator(llm):
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
trader_decision = state["trader_investment_plan"] 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: 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} Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report} Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report} Latest World Affairs Report: {news_report}

View File

@@ -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): def create_neutral_debator(llm):
@@ -14,6 +17,7 @@ def create_neutral_debator(llm):
sentiment_report = state["sentiment_report"] sentiment_report = state["sentiment_report"]
news_report = state["news_report"] news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"] fundamentals_report = state["fundamentals_report"]
instrument_context = get_instrument_context_from_state(state)
trader_decision = state["trader_investment_plan"] 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: 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} Market Research Report: {market_research_report}
Social Media Sentiment Report: {sentiment_report} Social Media Sentiment Report: {sentiment_report}
Latest World Affairs Report: {news_report} Latest World Affairs Report: {news_report}

View File

@@ -8,7 +8,7 @@ from langchain_core.messages import AIMessage
from tradingagents.agents.schemas import TraderProposal, render_trader_proposal from tradingagents.agents.schemas import TraderProposal, render_trader_proposal
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context, get_instrument_context_from_state,
get_language_instruction, get_language_instruction,
) )
from tradingagents.agents.utils.structured import ( from tradingagents.agents.utils.structured import (
@@ -22,8 +22,7 @@ def create_trader(llm):
def trader_node(state, name): def trader_node(state, name):
company_name = state["company_of_interest"] company_name = state["company_of_interest"]
asset_type = state.get("asset_type", "stock") instrument_context = get_instrument_context_from_state(state)
instrument_context = build_instrument_context(company_name, asset_type)
investment_plan = state["investment_plan"] investment_plan = state["investment_plan"]
messages = [ messages = [

View File

@@ -46,6 +46,7 @@ class RiskDebateState(TypedDict):
class AgentState(MessagesState): class AgentState(MessagesState):
company_of_interest: Annotated[str, "Company that we are interested in trading"] 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"] 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"] trade_date: Annotated[str, "What date we are trading at"]
sender: Annotated[str, "Agent that sent this message"] sender: Annotated[str, "Agent that sent this message"]

View File

@@ -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 from langchain_core.messages import HumanMessage, RemoveMessage
# Import tools from separate utility files # Import tools from separate utility files
@@ -19,6 +24,8 @@ from tradingagents.agents.utils.news_data_tools import (
get_global_news get_global_news
) )
logger = logging.getLogger(__name__)
def get_language_instruction() -> str: def get_language_instruction() -> str:
"""Return a prompt instruction for the configured output language. """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}." return f" Write your entire response in {lang}."
def build_instrument_context(ticker: str, asset_type: str = "stock") -> str: def _clean_identity_value(value: Any) -> Optional[str]:
"""Describe the exact instrument so agents preserve exchange-qualified tickers.""" """Return a trimmed string, or None for empty / placeholder-ish values."""
instrument_label = "asset" if asset_type == "crypto" else "instrument" if not isinstance(value, str):
extra_hint = ( return None
" Treat it as a crypto asset rather than a company, and do not assume company fundamentals are available." cleaned = value.strip()
if asset_type == "crypto" if not cleaned or cleaned.lower() in {"none", "n/a", "nan", "null"}:
else "" 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}`. " f"The {instrument_label} to analyze is `{ticker}`. "
"Use this exact ticker in every tool call, report, and recommendation, " "Use this exact ticker in every tool call, report, and recommendation, "
"preserving any exchange suffix (e.g. `.TO`, `.L`, `.HK`, `.T`, `-USD`)." "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 create_msg_delete():
def delete_messages(state): def delete_messages(state):
"""Clear messages and add placeholder for Anthropic compatibility""" """Clear messages and add a context-anchored placeholder.
messages = state["messages"]
# 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] removal_operations = [RemoveMessage(id=m.id) for m in messages]
# Add a minimal placeholder message instrument_context = get_instrument_context_from_state(state)
placeholder = HumanMessage(content="Continue") 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 {"messages": removal_operations + [placeholder]}
return delete_messages return delete_messages

View File

@@ -21,12 +21,21 @@ class Propagator:
trade_date: str, trade_date: str,
asset_type: str = "stock", asset_type: str = "stock",
past_context: str = "", past_context: str = "",
instrument_context: str = "",
) -> Dict[str, Any]: ) -> 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 { return {
"messages": [("human", company_name)], "messages": [("human", company_name)],
"company_of_interest": company_name, "company_of_interest": company_name,
"asset_type": asset_type, "asset_type": asset_type,
"instrument_context": instrument_context,
"trade_date": str(trade_date), "trade_date": str(trade_date),
"past_context": past_context, "past_context": past_context,
"investment_debate_state": InvestDebateState( "investment_debate_state": InvestDebateState(

View File

@@ -28,6 +28,8 @@ from tradingagents.dataflows.config import set_config
# Import the new abstract tool methods from agent_utils # Import the new abstract tool methods from agent_utils
from tradingagents.agents.utils.agent_utils import ( from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
resolve_instrument_identity,
get_stock_data, get_stock_data,
get_indicators, get_indicators,
get_fundamentals, get_fundamentals,
@@ -292,6 +294,18 @@ class TradingAgentsGraph:
if updates: if updates:
self.memory_log.batch_update_with_outcomes(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"): def propagate(self, company_name, trade_date, asset_type: str = "stock"):
"""Run the trading agents graph for a company on a specific date. """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"): 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.""" """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) 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( 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() args = self.propagator.get_graph_args()