feat: structured-output Trader and Research Manager (#434, finishes the trio)

Extends the canonical structured-output pattern from the Portfolio Manager
to the other two decision-making agents.  Each of the three agents now
returns a typed Pydantic instance via llm.with_structured_output() in a
single primary call, and a render helper turns the result into the same
markdown shape downstream agents and saved reports already consume.

- ResearchPlan: 5-tier recommendation, conversational rationale, concrete
  strategic actions for the trader.
- TraderProposal: 3-tier action (transaction direction is naturally Buy /
  Hold / Sell — position sizing happens later at the Portfolio Manager),
  reasoning, and optional entry_price / stop_loss / position_sizing.
  Rendered output preserves the trailing "FINAL TRANSACTION PROPOSAL:
  **BUY/HOLD/SELL**" line for backward compatibility with the analyst
  stop-signal text.
- PortfolioDecision: 5-tier rating, executive summary, investment thesis,
  optional price_target / time_horizon (unchanged).

The shared try-structured-then-fallback pattern is extracted into
tradingagents/agents/utils/structured.py (bind_structured +
invoke_structured_or_freetext) so all three agents go through the same
code path and log the same warning when a provider lacks structured
output and the agent falls back to free-text generation.

Net effect for users: every saved markdown report (research/manager.md,
trading/trader.md, portfolio/decision.md) now has consistent section
headers across runs and providers, easier to scan.

Net effect for the runtime: the rating extraction round-trip is gone —
the rating comes from the structured response itself, not a second
LLM call. SignalProcessor was already simplified to a heuristic adapter
in the previous commit.

11 new tests in tests/test_structured_agents.py cover the Trader and
Research Manager render functions, structured-output happy paths, and
free-text fallback. Full suite: 88 tests pass in ~2s without API keys.
This commit is contained in:
Yijia-Xiao
2026-04-25 20:27:23 +00:00
parent 0fda24515f
commit bba147798f
6 changed files with 519 additions and 69 deletions
@@ -5,35 +5,24 @@ Uses LangChain's ``with_structured_output`` so the LLM produces a typed
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.
back gracefully to free-text generation.
"""
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__)
from tradingagents.agents.utils.structured import (
bind_structured,
invoke_structured_or_freetext,
)
def create_portfolio_manager(llm):
# 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
structured_llm = bind_structured(llm, PortfolioDecision, "Portfolio Manager")
def portfolio_manager_node(state) -> dict:
instrument_context = build_instrument_context(state["company_of_interest"])
@@ -74,7 +63,13 @@ def create_portfolio_manager(llm):
Be decisive and ground every conclusion in specific evidence from the analysts.{get_language_instruction()}"""
final_trade_decision = _invoke_pm(structured_llm, llm, prompt)
final_trade_decision = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_pm_decision,
"Portfolio Manager",
)
new_risk_debate_state = {
"judge_decision": final_trade_decision,
@@ -95,26 +90,3 @@ Be decisive and ground every conclusion in specific evidence from the analysts.{
}
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
@@ -1,8 +1,18 @@
"""Research Manager: turns the bull/bear debate into a structured investment plan for the trader."""
from __future__ import annotations
from tradingagents.agents.schemas import ResearchPlan, render_research_plan
from tradingagents.agents.utils.agent_utils import build_instrument_context
from tradingagents.agents.utils.structured import (
bind_structured,
invoke_structured_or_freetext,
)
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"])
history = state["investment_debate_state"].get("history", "")
@@ -24,31 +34,31 @@ def create_research_manager(llm):
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)
investment_plan = invoke_structured_or_freetext(
structured_llm,
llm,
prompt,
render_research_plan,
"Research Manager",
)
new_investment_debate_state = {
"judge_decision": response.content,
"judge_decision": investment_plan,
"history": investment_debate_state.get("history", ""),
"bear_history": investment_debate_state.get("bear_history", ""),
"bull_history": investment_debate_state.get("bull_history", ""),
"current_response": response.content,
"current_response": investment_plan,
"count": investment_debate_state["count"],
}
return {
"investment_debate_state": new_investment_debate_state,
"investment_plan": response.content,
"investment_plan": investment_plan,
}
return research_manager_node