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

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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"]

View File

@@ -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"]

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):
@@ -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}

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):
@@ -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}

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):
@@ -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}

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):
@@ -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}

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):
@@ -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}

View File

@@ -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 = [

View File

@@ -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"]

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
# 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

View File

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

View File

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