mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-27 15:02:39 +03:00
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
This commit is contained in:
@@ -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_rating": "Hold", "final_trade_decision": "**Rating**: Buy"}) == "Hold"
|
||||||
assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell"
|
assert run_rating({"final_trade_decision": "**Rating**: Sell\n\nExit."}) == "Sell"
|
||||||
assert run_rating({}) == RATING_REVIEW
|
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"
|
||||||
|
|||||||
@@ -32,10 +32,20 @@ RATING_REVIEW = "REVIEW"
|
|||||||
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
|
_RATING_SET = {r.lower() for r in RATINGS_5_TIER}
|
||||||
|
|
||||||
# Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown
|
# Matches "Rating: X" / "rating - X" / "Rating — **X**" — tolerates markdown
|
||||||
# bold wrappers and any dash or colon a model writes as the separator.
|
# bold wrappers and any dash or colon a model writes as the separator. "rating"
|
||||||
_RATING_LABEL_RE = re.compile(r"rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
|
# must start a word, so "Operating margin: Sell-side" is not a label.
|
||||||
|
_RATING_LABEL_RE = re.compile(r"(?<![a-z])rating\b[^:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
# The same label opening its own line ("**Rating**: X", "## Final Rating - X",
|
||||||
|
# "Our rating: X"): the shape the Portfolio Manager is asked to write its
|
||||||
|
# decision in. Only emphasis and heading marks may precede it, so a list item,
|
||||||
|
# table row or blockquote quoting someone else's rating is not one.
|
||||||
|
_RATING_LINE_RE = re.compile(
|
||||||
|
r"[\s*_#]*(?:\w+\s+)?rating[^\w:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(\w+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
|
# A line presenting the scale rather than a decision ("Rating Scale: Buy, ...").
|
||||||
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
|
_RATING_SCALE_RE = re.compile(r"rating\s*(scale|options|legend)", re.IGNORECASE)
|
||||||
|
|
||||||
@@ -50,25 +60,31 @@ def extract_rating(text: str) -> str | None:
|
|||||||
|
|
||||||
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
|
Two-pass strategy on the NFKC-normalized text (so fullwidth punctuation like
|
||||||
``Rating:Overweight`` is matched the same as ASCII):
|
``Rating:Overweight`` is matched the same as ASCII):
|
||||||
1. An explicit "Rating: X" label (tolerant of markdown bold).
|
1. An explicit "Rating: X" label (tolerant of markdown bold): the first one
|
||||||
2. The first standalone 5-tier rating word found anywhere.
|
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:
|
if not text:
|
||||||
return None
|
return None
|
||||||
norm = unicodedata.normalize("NFKC", text)
|
norm = unicodedata.normalize("NFKC", text)
|
||||||
|
|
||||||
# The labelled rating, taking the last one written: a decision states its
|
# A decision is asked to open with its rating on its own line, so the first
|
||||||
# rating after discussing the alternatives. Lines presenting the scale
|
# such line is the call; later ones may quote someone else's ("Consensus
|
||||||
# itself are a legend the model echoed, not a call.
|
# rating: Buy"). Without one, the last label anywhere wins: prose states its
|
||||||
labelled = None
|
# 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():
|
for line in norm.splitlines():
|
||||||
if _RATING_SCALE_RE.search(line):
|
if _RATING_SCALE_RE.search(line):
|
||||||
continue
|
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)
|
m = _RATING_LABEL_RE.search(line)
|
||||||
if m and m.group(1).lower() in _RATING_SET:
|
if m and m.group(1).lower() in _RATING_SET:
|
||||||
labelled = m.group(1).capitalize()
|
anywhere = m.group(1).capitalize()
|
||||||
if labelled:
|
if on_own_line or anywhere:
|
||||||
return labelled
|
return on_own_line or anywhere
|
||||||
|
|
||||||
# No label. A single rating word in the text is the call; several are an
|
# 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 --
|
# argument, and picking one of them reports a direction nobody decided --
|
||||||
|
|||||||
Reference in New Issue
Block a user