feat: structured-output Portfolio Manager + 5-tier rating consistency (#434)

Three related changes that take the rating pipeline from heuristic-only
to type-safe at the source.

1) Research Manager prompt now uses the same 5-tier scale (Buy /
   Overweight / Hold / Underweight / Sell) as the Portfolio Manager,
   signal_processing, and the memory log.  The prior 3-tier wording
   (Buy / Sell / Hold) was the only remaining inconsistency in the
   pipeline.

2) Centralise the 5-tier vocabulary and the heuristic prose-rating
   parser into tradingagents/agents/utils/rating.py.  Both the memory
   log and the signal processor now share the same parser instead of
   duplicating regex and word-walker logic.

3) Make structured output a first-class part of the Portfolio Manager's
   primary call.  The PM uses llm.with_structured_output(PortfolioDecision)
   so each provider's native structured-output mode (json_schema for
   OpenAI/xAI, response_schema for Gemini, tool-use for Anthropic,
   function_calling for OpenAI-compatible providers) yields a typed
   Pydantic instance directly.  A render helper turns that instance back
   into the same markdown shape downstream consumers (memory log, CLI
   display, saved reports) already expect, so no other code has to know
   the PM now produces structured output.  Providers without structured
   support fall back gracefully to free-text + the deterministic
   heuristic.

   The previous SignalProcessor had been making a second LLM call to
   re-extract the rating from the PM's prose; that round-trip is now
   eliminated.  SignalProcessor is a thin adapter over parse_rating(),
   makes zero LLM calls, and stays for backwards compatibility with
   process_signal() callers.

Schema (PortfolioDecision) captures rating + executive_summary +
investment_thesis + optional price_target + time_horizon, with field
descriptions doubling as output instructions.  Agent prose remains the
primary artifact; structured output is layered onto the PM only because
it is the one agent whose output has machine-readable downstream
consumers.

15 new tests cover the heuristic parser (markdown-bold edge cases that
had no coverage before), the structured PM happy path, the free-text
fallback path, and that SignalProcessor never invokes the LLM.  Full
suite: 77 tests pass in ~2s without API keys.
This commit is contained in:
Yijia-Xiao
2026-04-25 19:57:26 +00:00
parent 4cbd4b086f
commit 0fda24515f
8 changed files with 399 additions and 87 deletions
@@ -1,29 +1,53 @@
from tradingagents.agents.utils.agent_utils import build_instrument_context, get_language_instruction
"""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 to a free-text invocation and the existing heuristic rating parser.
"""
from __future__ import annotations
import logging
from tradingagents.agents.schemas import PortfolioDecision, render_pm_decision
from tradingagents.agents.utils.agent_utils import (
build_instrument_context,
get_language_instruction,
)
logger = logging.getLogger(__name__)
def create_portfolio_manager(llm):
def portfolio_manager_node(state) -> dict:
# Wrap once at agent construction; if the provider does not support
# structured output we keep ``structured_llm`` as None and use the
# free-text fallback for every call.
try:
structured_llm = llm.with_structured_output(PortfolioDecision)
except (NotImplementedError, AttributeError) as exc:
logger.warning(
"Portfolio Manager: provider does not support with_structured_output (%s); "
"falling back to free-text generation",
exc,
)
structured_llm = None
def portfolio_manager_node(state) -> dict:
instrument_context = build_instrument_context(state["company_of_interest"])
history = state["risk_debate_state"]["history"]
risk_debate_state = state["risk_debate_state"]
market_research_report = state["market_report"]
news_report = state["news_report"]
fundamentals_report = state["fundamentals_report"]
sentiment_report = state["sentiment_report"]
research_plan = state["investment_plan"]
trader_plan = state["trader_investment_plan"]
past_context = state.get("past_context", "")
lessons_line = (
f"- Lessons from prior decisions and outcomes:\n{past_context}\n"
if past_context else ""
)
thesis_instruction = (
"3. **Investment Thesis**: Detailed reasoning anchored in the analysts' debate and the lessons from prior decisions."
if past_context
else "3. **Investment Thesis**: Detailed reasoning anchored in the analysts' debate."
else ""
)
prompt = f"""As the Portfolio Manager, synthesize the risk analysts' debate and deliver the final trading decision.
@@ -43,14 +67,6 @@ def create_portfolio_manager(llm):
- Research Manager's investment plan: **{research_plan}**
- Trader's transaction proposal: **{trader_plan}**
{lessons_line}
**Required Output Structure:**
1. **Rating**: State one of Buy / Overweight / Hold / Underweight / Sell.
2. **Executive Summary**: A concise action plan covering entry strategy, position sizing, key risk levels, and time horizon.
{thesis_instruction}
---
**Risk Analysts Debate History:**
{history}
@@ -58,10 +74,10 @@ def create_portfolio_manager(llm):
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}"""
response = llm.invoke(prompt)
final_trade_decision = _invoke_pm(structured_llm, llm, prompt)
new_risk_debate_state = {
"judge_decision": response.content,
"judge_decision": final_trade_decision,
"history": risk_debate_state["history"],
"aggressive_history": risk_debate_state["aggressive_history"],
"conservative_history": risk_debate_state["conservative_history"],
@@ -75,7 +91,30 @@ Be decisive and ground every conclusion in specific evidence from the analysts.{
return {
"risk_debate_state": new_risk_debate_state,
"final_trade_decision": response.content,
"final_trade_decision": final_trade_decision,
}
return portfolio_manager_node
def _invoke_pm(structured_llm, plain_llm, prompt: str) -> str:
"""Run the PM call and return the markdown-rendered decision.
Tries the structured-output path first; if it fails for any reason
(provider does not support it, model returns malformed JSON, network
glitch on the structured endpoint), falls back to the plain free-text
invocation so the pipeline still produces a result.
"""
if structured_llm is not None:
try:
decision = structured_llm.invoke(prompt)
return render_pm_decision(decision)
except Exception as exc:
logger.warning(
"Portfolio Manager: structured-output invocation failed (%s); "
"retrying once as free text",
exc,
)
response = plain_llm.invoke(prompt)
return response.content
@@ -9,21 +9,31 @@ def create_research_manager(llm):
investment_debate_state = state["investment_debate_state"]
prompt = f"""As the portfolio manager and debate facilitator, your role is to critically evaluate this round of debate and make a definitive decision: align with the bear analyst, the bull analyst, or choose Hold only if it is strongly justified based on the arguments presented.
Summarize the key points from both sides concisely, focusing on the most compelling evidence or reasoning. Your recommendation—Buy, Sell, or Hold—must be clear and actionable. Avoid defaulting to Hold simply because both sides have valid points; commit to a stance grounded in the debate's strongest arguments.
Additionally, develop a detailed investment plan for the trader. This should include:
Your Recommendation: A decisive stance supported by the most convincing arguments.
Rationale: An explanation of why these arguments lead to your conclusion.
Strategic Actions: Concrete steps for implementing the recommendation.
Present your analysis conversationally, as if speaking naturally, without special formatting.
prompt = f"""As the Research Manager and debate facilitator, your role is to critically evaluate this round of debate and deliver a clear, actionable investment plan for the trader.
{instrument_context}
Here is the debate:
Debate History:
---
**Rating Scale** (use exactly one):
- **Buy**: Strong conviction in the bull thesis; recommend taking or growing the position
- **Overweight**: Constructive view; recommend gradually increasing exposure
- **Hold**: Balanced view; recommend maintaining the current position
- **Underweight**: Cautious view; recommend trimming exposure
- **Sell**: Strong conviction in the bear thesis; recommend exiting or avoiding the position
Commit to a clear stance whenever the debate's strongest arguments warrant one; reserve Hold for situations where the evidence on both sides is genuinely balanced.
**Required Output Structure:**
1. **Recommendation**: State one of Buy / Overweight / Hold / Underweight / Sell.
2. **Rationale**: Summarise the key points from both sides and explain which arguments led to this recommendation.
3. **Strategic Actions**: Concrete steps for the trader to implement the recommendation, including position sizing guidance consistent with the rating.
Present your analysis conversationally, as if speaking naturally to a teammate.
---
**Debate History:**
{history}"""
response = llm.invoke(prompt)