mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-27 15:02:39 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user