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
@@ -1,11 +1,11 @@
"""Portfolio Manager: synthesises the risk-analyst debate into the final decision.
Uses LangChain's ``with_structured_output`` so the LLM produces a typed
``PortfolioDecision`` directly, in a single call. The result is rendered
back to markdown for storage in ``final_trade_decision`` so memory log,
CLI display, and saved reports continue to consume the same shape they do
today. When a provider does not expose structured output, the agent falls
back gracefully to free-text generation.
``PortfolioDecision`` directly, in a single call. Its rating is the run's
``final_rating``, and the decision is rendered to markdown as
``final_trade_decision`` for the memory log, CLI display and saved reports.
When a provider does not expose structured output, the agent falls back to
free-text generation and the rating is read from that text.
"""
from __future__ import annotations
@@ -15,12 +15,9 @@ from tradingagents.agents.context import (
get_language_instruction,
get_portfolio_context_from_state,
)
from tradingagents.agents.rating import parse_rating
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.structured import (
NO_EXTERNAL_TOOLS,
bind_structured,
invoke_structured_or_freetext,
)
from tradingagents.agents.structured import NO_EXTERNAL_TOOLS, bind_structured, invoke_structured
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()}"""
final_trade_decision = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_pm_decision,
"Portfolio Manager",
)
# The typed rating is the decision; the rendered text only carries it.
# Read back from text, a rating the thesis quotes could replace it.
decision = invoke_structured(structured_llm, prompt, "Portfolio Manager")
if decision is not None:
final_trade_decision = render_pm_decision(decision)
final_rating = decision.rating.value
else:
final_trade_decision = llm.invoke(prompt).content
final_rating = parse_rating(final_trade_decision)
new_risk_debate_state = {
"judge_decision": final_trade_decision,
@@ -102,6 +101,7 @@ Write these sections, in this order, starting with the rating on its own line:
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": final_trade_decision,
"final_rating": final_rating,
}
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 Research Manager (investment plan recommendation)
- The Portfolio Manager (final position decision)
- The signal processor (rating extracted for downstream consumers)
- The Portfolio Manager (final position decision; its free-text fallback is read here)
- The memory log (rating tag stored alongside each decision entry)
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
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:
"""Whether a signal is the non-tradeable REVIEW sentinel (#1170)."""
return signal == RATING_REVIEW
+1
View File
@@ -73,5 +73,6 @@ class AgentState(MessagesState):
RiskDebateState, "Current state of the debate on evaluating risk"
]
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)"]
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
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(
structured_llm: Any | None,
plain_llm: Any,
@@ -63,27 +88,8 @@ def invoke_structured_or_freetext(
render: Callable[[T], str],
agent_name: str,
) -> str:
"""Run the structured call and render to markdown; fall back to free-text on any failure.
``prompt`` is whatever the underlying LLM accepts (a string for chat
invocations, a list of message dicts for chat models that take that
shape). The same value is forwarded to the free-text path so the
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
"""Run the structured call and render to markdown; fall back to free-text on any failure."""
result = invoke_structured(structured_llm, prompt, agent_name)
if result is not None:
return render(result)
return plain_llm.invoke(prompt).content
+5 -7
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
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.date_window import get_current_date
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)
return
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",
@@ -341,7 +342,7 @@ class TradingAgentsGraph:
# Clear checkpoint on successful completion to avoid stale state.
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):
"""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"],
"final_trade_decision": final_state["final_trade_decision"],
"final_rating": run_rating(final_state),
}
# 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:
# Reports can be in any language and this file is read by a person.
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,
trade_date: str,
final_trade_decision: str,
rating: str | 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:
return
# Idempotency guard: fast raw-text scan instead of full parse. Any entry
@@ -45,7 +50,7 @@ class TradingMemoryLog:
for line in raw.splitlines():
if line.startswith(f"[{trade_date} | {ticker} |") and line.endswith("]"):
return
rating = parse_rating(final_trade_decision)
rating = rating or parse_rating(final_trade_decision)
tag = f"[{trade_date} | {ticker} | {rating} | pending]"
entry = f"{tag}\n\nDECISION:\n{final_trade_decision}{self._SEPARATOR}"
with open(self._log_path, "a", encoding="utf-8") as f: