fix(agents): carry the Portfolio Manager's rating through the run (#1383)

- the typed rating is the state's final_rating; propagate returns it, and the memory log tag, the state log and the CLI review check read it
- the decision text is parsed only when the Portfolio Manager answered in free text
- TradingAgentsGraph.process_signal is removed
This commit is contained in:
Yijia-Xiao
2026-09-24 18:45:44 +00:00
parent 05878c96a8
commit a94a411b0e
14 changed files with 93 additions and 87 deletions
+2 -2
View File
@@ -23,7 +23,7 @@ from cli.display import (
) )
from cli.selections import get_user_selections from cli.selections import get_user_selections
from cli.stats_handler import StatsCallbackHandler from cli.stats_handler import StatsCallbackHandler
from tradingagents.agents.rating import is_review from tradingagents.agents.rating import is_review, run_rating
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.graph.analyst_execution import ( from tradingagents.graph.analyst_execution import (
@@ -358,7 +358,7 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None):
# A decision nobody can read is not a position. Say so here rather than # A decision nobody can read is not a position. Say so here rather than
# leaving the run to look like a normal result. # leaving the run to look like a normal result.
if is_review(graph.process_signal(final_state.get("final_trade_decision", ""))): if is_review(run_rating(final_state)):
console.print( console.print(
"[yellow]No rating could be read from the final decision, so this run " "[yellow]No rating could be read from the final decision, so this run "
"is recorded for review rather than as a position. Re-run, or read the " "is recorded for review rather than as a position. Re-run, or read the "
+1 -5
View File
@@ -76,10 +76,6 @@ class _FakeGraph:
self.calls.append(("create_run_state", ticker, trade_date)) self.calls.append(("create_run_state", ticker, trade_date))
return {"messages": [], "company_of_interest": ticker} return {"messages": [], "company_of_interest": ticker}
def process_signal(self, text):
from tradingagents.agents.rating import parse_rating
return parse_rating(text)
def record_decision(self, ticker, trade_date, final_state): def record_decision(self, ticker, trade_date, final_state):
self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision"))) self.calls.append(("record_decision", ticker, trade_date, final_state.get("final_trade_decision")))
@@ -101,7 +97,7 @@ class _FakeGraph:
def stream(self, graph_input, **kwargs): def stream(self, graph_input, **kwargs):
yield {"messages": [], "market_report": "M"} yield {"messages": [], "market_report": "M"}
yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA."} yield {"messages": [], "final_trade_decision": "Rating: Buy\n\nBuy NVDA.", "final_rating": "Buy"}
class _NullLive: class _NullLive:
+1 -1
View File
@@ -46,7 +46,7 @@ def _state(ticker, final="评级: 买入"):
"company_of_interest": ticker, "trade_date": "2026-09-01", "company_of_interest": ticker, "trade_date": "2026-09-01",
"market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻", "market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻",
"fundamentals_report": "基本面", "investment_plan": "计划", "fundamentals_report": "基本面", "investment_plan": "计划",
"trader_investment_plan": "交易计划", "final_trade_decision": final, "trader_investment_plan": "交易计划", "final_trade_decision": final, "final_rating": "REVIEW",
"investment_debate_state": {"bull_history": "", "bear_history": "", "history": "", "investment_debate_state": {"bull_history": "", "bear_history": "", "history": "",
"current_response": "", "judge_decision": "", "count": 0}, "current_response": "", "judge_decision": "", "count": 0},
"risk_debate_state": {"aggressive_history": "", "conservative_history": "", "risk_debate_state": {"aggressive_history": "", "conservative_history": "",
+4 -2
View File
@@ -34,8 +34,10 @@ STRUCTURED = {
schemas.ResearchPlan: schemas.ResearchPlan( schemas.ResearchPlan: schemas.ResearchPlan(
recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"), recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"),
schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"), schemas.TraderProposal: schemas.TraderProposal(action=schemas.TraderAction.BUY, reasoning="r"),
# The thesis quotes another party's rating; the decision is still the PM's own.
schemas.PortfolioDecision: schemas.PortfolioDecision( schemas.PortfolioDecision: schemas.PortfolioDecision(
rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s", investment_thesis="t"), rating=schemas.PortfolioRating.OVERWEIGHT, executive_summary="s",
investment_thesis="Street consensus rating: Buy (28 of 35 analysts)."),
schemas.SentimentReport: schemas.SentimentReport( schemas.SentimentReport: schemas.SentimentReport(
overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"), overall_band=schemas.SentimentBand.NEUTRAL, overall_score=5.0, confidence="low", narrative="n"),
} }
@@ -126,7 +128,7 @@ def test_a_full_run_reaches_a_logged_decision(tmp_path, monkeypatch, offline, st
state, signal = graph.propagate("NVDA", TRADE_DATE) state, signal = graph.propagate("NVDA", TRADE_DATE)
assert signal == "Overweight" assert signal == state["final_rating"] == "Overweight"
for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report", for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report",
"investment_plan", "trader_investment_plan", "final_trade_decision"): "investment_plan", "trader_investment_plan", "final_trade_decision"):
assert state[key].strip(), key assert state[key].strip(), key
+1 -1
View File
@@ -898,6 +898,7 @@ class TestLegacyRemoval:
fake_state = { fake_state = {
"final_trade_decision": "Rating: Buy\nBuy NVDA.", "final_trade_decision": "Rating: Buy\nBuy NVDA.",
"final_rating": "Buy",
"company_of_interest": "NVDA", "company_of_interest": "NVDA",
"trade_date": "2026-01-10", "trade_date": "2026-01-10",
"market_report": "", "market_report": "",
@@ -924,7 +925,6 @@ class TestLegacyRemoval:
mock_graph.graph.invoke.return_value = fake_state mock_graph.graph.invoke.return_value = fake_state
mock_graph.propagator.create_initial_state.return_value = fake_state mock_graph.propagator.create_initial_state.return_value = fake_state
mock_graph.propagator.get_graph_args.return_value = {} mock_graph.propagator.get_graph_args.return_value = {}
mock_graph.process_signal.return_value = "Buy"
# Bind the real _run_graph so propagate's call to self._run_graph executes # Bind the real _run_graph so propagate's call to self._run_graph executes
# the actual write path instead of the auto-MagicMock. # the actual write path instead of the auto-MagicMock.
mock_graph._run_graph = functools.partial( mock_graph._run_graph = functools.partial(
+1 -2
View File
@@ -174,9 +174,8 @@ def test_completed_run_clears_the_checkpoint_it_wrote(tmp_path, monkeypatch):
graph.debug = False graph.debug = False
graph._resuming = False graph._resuming = False
graph.propagator.get_graph_args = lambda callbacks=None: {} graph.propagator.get_graph_args = lambda callbacks=None: {}
graph.process_signal = lambda d: "Hold"
graph._log_state = lambda *a, **k: None graph._log_state = lambda *a, **k: None
graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx"}})() graph.graph = type("G", (), {"invoke": lambda self, i, **k: {"final_trade_decision": "Rating: Hold\n\nx", "final_rating": "Hold"}})()
book = PortfolioContext.model_validate(HOLDING) book = PortfolioContext.model_validate(HOLDING)
written = graph._run_signature("stock", book) # what begin_checkpoint keys on written = graph._run_signature("stock", book) # what begin_checkpoint keys on
+13 -5
View File
@@ -121,10 +121,6 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
def record_decision(self, *a, **k): def record_decision(self, *a, **k):
pass pass
def process_signal(self, text):
from tradingagents.agents.rating import parse_rating
return parse_rating(text)
def get_graph_args(self, callbacks=None): def get_graph_args(self, callbacks=None):
return {} return {}
@@ -141,7 +137,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
pass pass
def stream(self, *a, **k): def stream(self, *a, **k):
yield {"messages": [], "final_trade_decision": REFUSAL} yield {"messages": [], "final_trade_decision": REFUSAL, "final_rating": RATING_REVIEW}
fake = _Graph() fake = _Graph()
fake.graph = fake fake.graph = fake
@@ -210,3 +206,15 @@ def test_a_decision_prompt_states_the_shape_of_its_answer(module, factory, must_
assert "## Output" in prompt, "no output-format section in the prompt" assert "## Output" in prompt, "no output-format section in the prompt"
section = prompt.split("## Output", 1)[1] section = prompt.split("## Output", 1)[1]
assert f"**{must_name}**" in section, section[:300] assert f"**{must_name}**" in section, section[:300]
@pytest.mark.unit
def test_a_state_without_the_typed_rating_reads_it_from_the_decision():
"""A run finished by an older version and resumed from its checkpoint has
no final_rating; every reader falls back the same way instead of one
raising and another reporting REVIEW."""
from tradingagents.agents.rating import run_rating
assert run_rating({"final_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold"
assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell"
assert run_rating({}) == RATING_REVIEW
-17
View File
@@ -75,20 +75,3 @@ class TestExtractRating:
# The memory log tags an unreadable decision REVIEW, never a tradeable rating. # The memory log tags an unreadable decision REVIEW, never a tradeable rating.
assert parse_rating("No rating here.") == RATING_REVIEW assert parse_rating("No rating here.") == RATING_REVIEW
assert parse_rating("No rating here.", default="Underweight") == "Underweight" assert parse_rating("No rating here.", default="Underweight") == "Underweight"
@pytest.mark.unit
class TestGraphSignalContract:
"""The graph-facing signal (TradingAgentsGraph.process_signal) honors the
documented "5-tier or REVIEW" contract, not just the parser in isolation."""
def _bare_graph(self):
from tradingagents.graph.trading_graph import TradingAgentsGraph
g = object.__new__(TradingAgentsGraph)
return g
def test_graph_surfaces_review(self):
assert self._bare_graph().process_signal("no rating in here") == RATING_REVIEW
def test_graph_returns_rating(self):
assert self._bare_graph().process_signal("**Rating**: Sell") == "Sell"
@@ -1,11 +1,11 @@
"""Portfolio Manager: synthesises the risk-analyst debate into the final decision. """Portfolio Manager: synthesises the risk-analyst debate into the final decision.
Uses LangChain's ``with_structured_output`` so the LLM produces a typed Uses LangChain's ``with_structured_output`` so the LLM produces a typed
``PortfolioDecision`` directly, in a single call. The result is rendered ``PortfolioDecision`` directly, in a single call. Its rating is the run's
back to markdown for storage in ``final_trade_decision`` so memory log, ``final_rating``, and the decision is rendered to markdown as
CLI display, and saved reports continue to consume the same shape they do ``final_trade_decision`` for the memory log, CLI display and saved reports.
today. When a provider does not expose structured output, the agent falls When a provider does not expose structured output, the agent falls back to
back gracefully to free-text generation. free-text generation and the rating is read from that text.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,12 +15,9 @@ from tradingagents.agents.context import (
get_language_instruction, get_language_instruction,
get_portfolio_context_from_state, get_portfolio_context_from_state,
) )
from tradingagents.agents.rating import parse_rating
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.structured import ( from tradingagents.agents.structured import NO_EXTERNAL_TOOLS, bind_structured, invoke_structured
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
def create_portfolio_manager(llm): def create_portfolio_manager(llm):
@@ -78,13 +75,15 @@ Write these sections, in this order, starting with the rating on its own line:
{NO_EXTERNAL_TOOLS}{get_language_instruction()}""" {NO_EXTERNAL_TOOLS}{get_language_instruction()}"""
final_trade_decision = invoke_structured_or_freetext( # The typed rating is the decision; the rendered text only carries it.
structured_llm, # Read back from text, a rating the thesis quotes could replace it.
llm, decision = invoke_structured(structured_llm, prompt, "Portfolio Manager")
prompt, if decision is not None:
render_pm_decision, final_trade_decision = render_pm_decision(decision)
"Portfolio Manager", final_rating = decision.rating.value
) else:
final_trade_decision = llm.invoke(prompt).content
final_rating = parse_rating(final_trade_decision)
new_risk_debate_state = { new_risk_debate_state = {
"judge_decision": final_trade_decision, "judge_decision": final_trade_decision,
@@ -102,6 +101,7 @@ Write these sections, in this order, starting with the rating on its own line:
return { return {
"risk_debate_state": new_risk_debate_state, "risk_debate_state": new_risk_debate_state,
"final_trade_decision": final_trade_decision, "final_trade_decision": final_trade_decision,
"final_rating": final_rating,
} }
return portfolio_manager_node return portfolio_manager_node
+10 -2
View File
@@ -2,8 +2,7 @@
The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by: The same five-tier scale (Buy, Overweight, Hold, Underweight, Sell) is used by:
- The Research Manager (investment plan recommendation) - The Research Manager (investment plan recommendation)
- The Portfolio Manager (final position decision) - The Portfolio Manager (final position decision; its free-text fallback is read here)
- The signal processor (rating extracted for downstream consumers)
- The memory log (rating tag stored alongside each decision entry) - The memory log (rating tag stored alongside each decision entry)
Centralising it here avoids drift between those call sites. Centralising it here avoids drift between those call sites.
@@ -88,6 +87,15 @@ def parse_rating(text: str, default: str = RATING_REVIEW) -> str:
return rating if rating is not None else default return rating if rating is not None else default
def run_rating(final_state: dict) -> str:
"""A finished run's rating: the Portfolio Manager's own, else read from its decision.
The fallback serves a state without ``final_rating``, such as a run an older
version completed and a checkpoint hands back unchanged.
"""
return final_state.get("final_rating") or parse_rating(final_state.get("final_trade_decision", ""))
def is_review(signal: str) -> bool: def is_review(signal: str) -> bool:
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170).""" """Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
return signal == RATING_REVIEW return signal == RATING_REVIEW
+1
View File
@@ -73,5 +73,6 @@ class AgentState(MessagesState):
RiskDebateState, "Current state of the debate on evaluating risk" RiskDebateState, "Current state of the debate on evaluating risk"
] ]
final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"] final_trade_decision: Annotated[str, "Final decision made by the Risk Analysts"]
final_rating: Annotated[str, "The Portfolio Manager's 5-tier rating, or REVIEW when it has none"]
past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"] past_context: Annotated[str, "Memory log context injected at run start (same-ticker decisions + cross-ticker lessons)"]
portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"] portfolio_context: Annotated[str, "Caller-supplied holdings and cash, rendered at run start; empty when not provided"]
+30 -24
View File
@@ -56,6 +56,31 @@ def bind_structured(llm: Any, schema: type[T], agent_name: str) -> Any | None:
return None return None
def invoke_structured(structured_llm: Any | None, prompt: Any, agent_name: str) -> T | None:
"""Run the structured call; ``None`` when there is none or it fails.
``prompt`` is whatever the underlying LLM accepts (a string for chat
invocations, a list of message dicts for chat models that take that
shape), so a caller can forward the same value to its free-text fallback.
"""
if structured_llm is None:
return None
try:
result = structured_llm.invoke(prompt)
if result is None:
# A thinking model can answer in plain text instead of calling
# the tool, leaving the parser with nothing to return. Treat it
# as a structured miss and fall back, with a clear reason.
raise ValueError("structured output returned no parsed result")
return result
except Exception as exc:
logger.warning(
"%s: structured-output invocation failed (%s); retrying once as free text",
agent_name, exc,
)
return None
def invoke_structured_or_freetext( def invoke_structured_or_freetext(
structured_llm: Any | None, structured_llm: Any | None,
plain_llm: Any, plain_llm: Any,
@@ -63,27 +88,8 @@ def invoke_structured_or_freetext(
render: Callable[[T], str], render: Callable[[T], str],
agent_name: str, agent_name: str,
) -> str: ) -> str:
"""Run the structured call and render to markdown; fall back to free-text on any failure. """Run the structured call and render to markdown; fall back to free-text on any failure."""
result = invoke_structured(structured_llm, prompt, agent_name)
``prompt`` is whatever the underlying LLM accepts (a string for chat if result is not None:
invocations, a list of message dicts for chat models that take that return render(result)
shape). The same value is forwarded to the free-text path so the return plain_llm.invoke(prompt).content
fallback sees the same input the structured call did.
"""
if structured_llm is not None:
try:
result = structured_llm.invoke(prompt)
if result is None:
# A thinking model can answer in plain text instead of calling
# the tool, leaving the parser with nothing to return. Treat it
# as a structured miss and fall back, with a clear reason.
raise ValueError("structured output returned no parsed result")
return render(result)
except Exception as exc:
logger.warning(
"%s: structured-output invocation failed (%s); retrying once as free text",
agent_name, exc,
)
response = plain_llm.invoke(prompt)
return response.content
+5 -7
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any from typing import Any
from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity from tradingagents.agents.context import build_instrument_context, resolve_instrument_identity
from tradingagents.agents.rating import parse_rating from tradingagents.agents.rating import run_rating
from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.config import run_config, set_config
from tradingagents.dataflows.date_window import get_current_date from tradingagents.dataflows.date_window import get_current_date
from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.dataflows.symbols import safe_ticker_component
@@ -295,7 +295,8 @@ class TradingAgentsGraph:
logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date) logger.warning("No final decision for %s on %s; nothing logged", company_name, trade_date)
return return
self.memory_log.store_decision( self.memory_log.store_decision(
ticker=company_name, trade_date=trade_date, final_trade_decision=decision ticker=company_name, trade_date=trade_date, final_trade_decision=decision,
rating=run_rating(final_state),
) )
def _run_graph(self, company_name, trade_date, asset_type: str = "stock", def _run_graph(self, company_name, trade_date, asset_type: str = "stock",
@@ -341,7 +342,7 @@ class TradingAgentsGraph:
# Clear checkpoint on successful completion to avoid stale state. # Clear checkpoint on successful completion to avoid stale state.
self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio) self.clear_checkpoint_on_success(company_name, trade_date, asset_type, portfolio)
return final_state, self.process_signal(final_state["final_trade_decision"]) return final_state, run_rating(final_state)
def _log_state(self, trade_date, final_state): def _log_state(self, trade_date, final_state):
"""Write a run's final state to JSON under the run's own ticker.""" """Write a run's final state to JSON under the run's own ticker."""
@@ -373,6 +374,7 @@ class TradingAgentsGraph:
}, },
"investment_plan": final_state["investment_plan"], "investment_plan": final_state["investment_plan"],
"final_trade_decision": final_state["final_trade_decision"], "final_trade_decision": final_state["final_trade_decision"],
"final_rating": run_rating(final_state),
} }
# A ticker that would escape the results directory is rejected. # A ticker that would escape the results directory is rejected.
@@ -384,7 +386,3 @@ class TradingAgentsGraph:
with open(log_path, "w", encoding="utf-8") as f: with open(log_path, "w", encoding="utf-8") as f:
# Reports can be in any language and this file is read by a person. # Reports can be in any language and this file is read by a person.
json.dump(entry, f, indent=4, ensure_ascii=False) json.dump(entry, f, indent=4, ensure_ascii=False)
def process_signal(self, full_signal):
"""The decision's 5-tier rating, or REVIEW when it has none."""
return parse_rating(full_signal)
+7 -2
View File
@@ -32,8 +32,13 @@ class TradingMemoryLog:
ticker: str, ticker: str,
trade_date: str, trade_date: str,
final_trade_decision: str, final_trade_decision: str,
rating: str | None = None,
) -> None: ) -> None:
"""Append pending entry at end of propagate(). No LLM call.""" """Append pending entry at end of propagate(). No LLM call.
``rating`` is the decision's own rating when the caller has it; without
one it is read from the decision text.
"""
if not self._log_path: if not self._log_path:
return return
# Idempotency guard: fast raw-text scan instead of full parse. Any entry # Idempotency guard: fast raw-text scan instead of full parse. Any entry
@@ -45,7 +50,7 @@ class TradingMemoryLog:
for line in raw.splitlines(): for line in raw.splitlines():
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"): if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"):
return return
rating = parse_rating(final_trade_decision) rating = rating or parse_rating(final_trade_decision)
tag = f"[{trade_date} | {ticker} | {rating} | pending]" tag = f"[{trade_date} | {ticker} | {rating} | pending]"
entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}" entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}"
with open(self._log_path, "a", encoding="utf-8") as f: with open(self._log_path, "a", encoding="utf-8") as f: