From b690dc79883f45a2a8a1fe6668869be1219d40e6 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 08:23:14 +0000 Subject: [PATCH] fix(rating): read a free-text rating from the decision's own rating line (#1383) - the first line opening with a rating label is the call, not a rating the text quotes later ("Consensus rating: Buy") - "rating" must start a word, so "Operating margin: Sell-side" is not a label --- tests/test_rating_integrity.py | 26 +++++++++++++++++++++++ tradingagents/agents/rating.py | 38 ++++++++++++++++++++++++---------- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 8fcadac4a..dc3c4373d 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -218,3 +218,29 @@ def test_a_state_without_the_typed_rating_reads_it_from_the_decision(): 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 + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "Street consensus rating: Buy (28 of 35 analysts).", + "Moody's affirmed the credit rating: Buy-side demand for the bonds stayed firm.", + "Operating margin: Sell-side estimates sit below guidance.", +]) +def test_a_rating_the_text_quotes_does_not_replace_the_decision(quoted): + """A free-text decision opens with its own rating line; a rating it quotes + as evidence, or a word merely ending in 'rating', is not the call.""" + text = f"**Rating**: Hold\n\n**Investment Thesis**: {quoted} We wait for margins." + assert extract_rating(text) == "Hold" + + +@pytest.mark.unit +@pytest.mark.parametrize("quoted", [ + "- Rating: Buy (Goldman Sachs, 12m target 180)", + "| Rating: Buy | Morgan Stanley |", + "> Rating: Buy, per the sell-side note", + "Street consensus rating: Buy", + "Consensus rating: Buy (28 of 35 analysts)", +]) +def test_a_quoted_rating_in_a_list_table_or_quote_is_not_the_decision(quoted): + text = f"Our rating: Hold\n\nWhat others say:\n{quoted}\n\nWe wait for margins." + assert extract_rating(text) == "Hold" diff --git a/tradingagents/agents/rating.py b/tradingagents/agents/rating.py index 37a6db8d6..16d7689dd 100644 --- a/tradingagents/agents/rating.py +++ b/tradingagents/agents/rating.py @@ -32,10 +32,20 @@ RATING_REVIEW = "REVIEW" _RATING_SET = {r.lower() for r in RATINGS_5_TIER} # Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown -# bold wrappers and any dash or colon a model writes as the separator. -_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)", +# bold wrappers and any dash or colon a model writes as the separator. "rating" +# must start a word, so "Operating margin: Sell-side" is not a label. +_RATING_LABEL_RE = re.compile(r"(? str | None: Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like ``Rating:Overweight`` is matched the same as ASCII): - 1. An explicit "Rating: X" label (tolerant of markdown bold). - 2. The first standalone 5-tier rating word found anywhere. + 1. An explicit "Rating: X" label (tolerant of markdown bold): the first one + opening its own line, else the last one anywhere. + 2. A single 5-tier rating word, when the text names only one. """ if not text: return None norm = unicodedata.normalize("NFKC", text) - # The labelled rating, taking the last one written: a decision states its - # rating after discussing the alternatives. Lines presenting the scale - # itself are a legend the model echoed, not a call. - labelled = None + # A decision is asked to open with its rating on its own line, so the first + # such line is the call; later ones may quote someone else's ("Consensus + # rating: Buy"). Without one, the last label anywhere wins: prose states its + # rating after discussing the alternatives. Lines presenting the scale itself + # are a legend the model echoed, not a call. + on_own_line = anywhere = None for line in norm.splitlines(): if _RATING_SCALE_RE.search(line): continue + m = _RATING_LINE_RE.match(line) + if on_own_line is None and m and m.group(1).lower() in _RATING_SET: + on_own_line = m.group(1).capitalize() m = _RATING_LABEL_RE.search(line) if m and m.group(1).lower() in _RATING_SET: - labelled = m.group(1).capitalize() - if labelled: - return labelled + anywhere = m.group(1).capitalize() + if on_own_line or anywhere: + return on_own_line or anywhere # No label. A single rating word in the text is the call; several are an # argument, and picking one of them reports a direction nobody decided --