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 -5
View File
@@ -76,10 +76,6 @@ class _FakeGraph:
self.calls.append(("create_run_state", ticker, trade_date))
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):
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):
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:
+1 -1
View File
@@ -46,7 +46,7 @@ def _state(ticker, final="评级: 买入"):
"company_of_interest": ticker, "trade_date": "2026-09-01",
"market_report": "市场", "sentiment_report": "情绪", "news_report": "新闻",
"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": "",
"current_response": "", "judge_decision": "", "count": 0},
"risk_debate_state": {"aggressive_history": "", "conservative_history": "",
+4 -2
View File
@@ -34,8 +34,10 @@ STRUCTURED = {
schemas.ResearchPlan: schemas.ResearchPlan(
recommendation=schemas.PortfolioRating.OVERWEIGHT, rationale="r", strategic_actions="a"),
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(
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(
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)
assert signal == "Overweight"
assert signal == state["final_rating"] == "Overweight"
for key in ("market_report", "sentiment_report", "news_report", "fundamentals_report",
"investment_plan", "trader_investment_plan", "final_trade_decision"):
assert state[key].strip(), key
+1 -1
View File
@@ -898,6 +898,7 @@ class TestLegacyRemoval:
fake_state = {
"final_trade_decision": "Rating: Buy\nBuy NVDA.",
"final_rating": "Buy",
"company_of_interest": "NVDA",
"trade_date": "2026-01-10",
"market_report": "",
@@ -924,7 +925,6 @@ class TestLegacyRemoval:
mock_graph.graph.invoke.return_value = fake_state
mock_graph.propagator.create_initial_state.return_value = fake_state
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
# the actual write path instead of the auto-MagicMock.
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._resuming = False
graph.propagator.get_graph_args = lambda callbacks=None: {}
graph.process_signal = lambda d: "Hold"
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)
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):
pass
def process_signal(self, text):
from tradingagents.agents.rating import parse_rating
return parse_rating(text)
def get_graph_args(self, callbacks=None):
return {}
@@ -141,7 +137,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path
pass
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
@@ -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"
section = prompt.split("## Output", 1)[1]
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.
assert parse_rating("No rating here.") == RATING_REVIEW
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"