diff --git a/tests/test_cli_decision_log.py b/tests/test_cli_decision_log.py index 26e39c4cb..a53a34232 100644 --- a/tests/test_cli_decision_log.py +++ b/tests/test_cli_decision_log.py @@ -76,8 +76,8 @@ class _FakeGraph: return {"messages": [], "company_of_interest": ticker} def process_signal(self, text): - from tradingagents.graph.signal_processing import SignalProcessor - return SignalProcessor.process_signal(None, text) + from tradingagents.agents.utils.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"))) diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 1425ed03c..b8fea6544 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -937,7 +937,7 @@ 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.signal_processor.process_signal.return_value = "Buy" + 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( diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 8d8fcb0cf..d50a0f7d3 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -75,13 +75,13 @@ def test_the_memory_log_records_review_rather_than_a_tradeable_hold(tmp_path): @pytest.mark.unit def test_the_signal_and_the_log_agree_on_the_same_decision(tmp_path): from tradingagents.agents.utils.memory import TradingMemoryLog - from tradingagents.graph.signal_processing import SignalProcessor + from tradingagents.agents.utils.rating import parse_rating log = TradingMemoryLog({"memory_log_path": str(tmp_path / "m.md")}) for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate."): log.store_decision("NVDA", f"2026-01-0{len(log.load_entries()) + 1}", text) - signals = [SignalProcessor.process_signal(None, text) + signals = [parse_rating(text) for text in (INVERTED, REFUSAL, "**Rating**: Buy\n\nAccumulate.")] assert [e["rating"] for e in log.load_entries()] == signals @@ -121,8 +121,8 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path pass def process_signal(self, text): - from tradingagents.graph.signal_processing import SignalProcessor - return SignalProcessor.process_signal(None, text) + from tradingagents.agents.utils.rating import parse_rating + return parse_rating(text) def get_graph_args(self, callbacks=None): return {} diff --git a/tests/test_signal_processing.py b/tests/test_signal_processing.py index bcb7ccdec..e77288144 100644 --- a/tests/test_signal_processing.py +++ b/tests/test_signal_processing.py @@ -1,11 +1,7 @@ -"""Tests for the shared rating heuristic and the SignalProcessor adapter. +"""The rating heuristic that reads the decision's 5-tier rating. -The Portfolio Manager produces a typed PortfolioDecision via structured -output and renders it to markdown that always contains a ``**Rating**: X`` -header. The deterministic heuristic in ``tradingagents.agents.utils.rating`` -is therefore sufficient to extract the rating downstream — no second LLM -call is needed — and SignalProcessor is now a thin adapter that delegates -to it. +The Portfolio Manager's rendered decision always carries a ``**Rating**: X`` +header, so the rating is read deterministically; no second model call is made. """ import pytest @@ -14,10 +10,8 @@ from tradingagents.agents.utils.rating import ( RATING_REVIEW, RATINGS_5_TIER, extract_rating, - is_review, parse_rating, ) -from tradingagents.graph.signal_processing import SignalProcessor # --------------------------------------------------------------------------- # Heuristic parser @@ -67,44 +61,9 @@ class TestParseRating: for r in RATINGS_5_TIER: assert parse_rating(f"Rating: {r}") == r - -# --------------------------------------------------------------------------- -# SignalProcessor: thin adapter over the heuristic -# --------------------------------------------------------------------------- - - -@pytest.mark.unit -class TestSignalProcessor: - def test_returns_rating_from_pm_markdown(self): - sp = SignalProcessor() - md = "**Rating**: Overweight\n\n**Executive Summary**: Build gradually." - assert sp.process_signal(md) == "Overweight" - - def test_makes_no_llm_calls(self): - """SignalProcessor must not invoke the LLM it was constructed with — - the rating is parseable from the rendered PM markdown directly.""" - from unittest.mock import MagicMock - - llm = MagicMock() - sp = SignalProcessor(llm) - sp.process_signal("Rating: Buy\nDetails.") - llm.invoke.assert_not_called() - llm.with_structured_output.assert_not_called() - - def test_unparseable_signal_is_review_not_silent_hold(self): - # #1170: an unrecognizable decision must surface REVIEW, not a fabricated - # tradeable Hold. - sp = SignalProcessor() - signal = sp.process_signal("Plain prose without a recommendation.") - assert signal == RATING_REVIEW - assert is_review(signal) - assert signal not in RATINGS_5_TIER - def test_fullwidth_colon_is_parsed_not_reviewed(self): - # #1170: `Rating:Overweight` (fullwidth colon) used to defeat the regex - # and silently become Hold; NFKC normalization now parses it. - sp = SignalProcessor() - assert sp.process_signal("Rating:Overweight\n理由はこちら。") == "Overweight" + # `Rating:Overweight` (fullwidth colon) is read, not sent to review (#1170). + assert parse_rating("Rating:Overweight\n理由はこちら。") == "Overweight" @pytest.mark.unit @@ -131,7 +90,6 @@ class TestGraphSignalContract: def _bare_graph(self): from tradingagents.graph.trading_graph import TradingAgentsGraph g = object.__new__(TradingAgentsGraph) - g.signal_processor = SignalProcessor() return g def test_graph_surfaces_review(self): diff --git a/tradingagents/graph/__init__.py b/tradingagents/graph/__init__.py index 901edddd8..ac66fceda 100644 --- a/tradingagents/graph/__init__.py +++ b/tradingagents/graph/__init__.py @@ -4,7 +4,6 @@ from .conditional_logic import ConditionalLogic from .propagation import Propagator from .reflection import Reflector from .setup import GraphSetup -from .signal_processing import SignalProcessor from .trading_graph import TradingAgentsGraph __all__ = [ @@ -13,5 +12,4 @@ __all__ = [ "GraphSetup", "Propagator", "Reflector", - "SignalProcessor", ] diff --git a/tradingagents/graph/signal_processing.py b/tradingagents/graph/signal_processing.py deleted file mode 100644 index 57f72178c..000000000 --- a/tradingagents/graph/signal_processing.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Extract the 5-tier portfolio rating from the Portfolio Manager's decision. - -The Portfolio Manager produces a typed ``PortfolioDecision`` via structured -output and renders it to markdown that always carries a ``**Rating**: X`` -header (see :func:`tradingagents.agents.schemas.render_pm_decision`). The -deterministic heuristic in :mod:`tradingagents.agents.utils.rating` is more -than sufficient to extract that rating; no extra LLM call is needed. - -This module exists for backwards compatibility with callers that expect a -``SignalProcessor.process_signal(text)`` interface. -""" - -from __future__ import annotations - -from typing import Any - -from tradingagents.agents.utils.rating import RATING_REVIEW, extract_rating - - -class SignalProcessor: - """Read the 5-tier rating out of a Portfolio Manager decision.""" - - def __init__(self, quick_thinking_llm: Any = None): - # The LLM argument is accepted for backwards compatibility but ignored: - # the PM's structured output guarantees the rating is parseable from the - # rendered markdown without a second LLM call, so it is not stored. - pass - - def process_signal(self, full_signal: str) -> str: - """Return one of Buy / Overweight / Hold / Underweight / Sell, or REVIEW. - - An unrecognizable decision yields ``REVIEW`` rather than a fabricated - ``Hold``, so a parsing failure is visible instead of masquerading as a - tradeable neutral signal (#1170). Consumers that map the result onto the - 5-tier enum should guard with :func:`~tradingagents.agents.utils.rating.is_review`. - """ - rating = extract_rating(full_signal) - return rating if rating is not None else RATING_REVIEW diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index b4a90d995..4ccde09dc 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -28,6 +28,7 @@ from tradingagents.agents.utils.agent_utils import ( resolve_instrument_identity, ) from tradingagents.agents.utils.memory import TradingMemoryLog +from tradingagents.agents.utils.rating import parse_rating from tradingagents.dataflows.config import run_config, set_config from tradingagents.dataflows.utils import get_current_date, safe_ticker_component from tradingagents.dataflows.y_finance import get_closes @@ -40,7 +41,6 @@ from .conditional_logic import ConditionalLogic from .propagation import Propagator from .reflection import Reflector from .setup import GraphSetup -from .signal_processing import SignalProcessor logger = logging.getLogger(__name__) @@ -163,7 +163,6 @@ class TradingAgentsGraph: max_recur_limit=self.config.get("max_recur_limit", 100), ) self.reflector = Reflector(self.quick_thinking_llm) - self.signal_processor = SignalProcessor(self.quick_thinking_llm) # State tracking self.curr_state = None @@ -655,5 +654,5 @@ class TradingAgentsGraph: json.dump(entry, f, indent=4, ensure_ascii=False) def process_signal(self, full_signal): - """Process a signal to extract the core decision.""" - return self.signal_processor.process_signal(full_signal) + """The decision's 5-tier rating, or REVIEW when it has none.""" + return parse_rating(full_signal)