"""Shared 5-tier rating vocabulary and a deterministic heuristic parser. 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; 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. ``extract_rating`` returns ``None`` when no rating can be found, and every caller turns that into ``REVIEW`` rather than a tradeable position: a decision nobody can read is not a Hold, and a Hold recorded in its place is quoted back to the next run as a call that was never made (#1170). """ from __future__ import annotations import re import unicodedata # Canonical, ordered 5-tier scale (most bullish to most bearish). RATINGS_5_TIER: tuple[str, ...] = ( "Buy", "Overweight", "Hold", "Underweight", "Sell", ) # Signal emitted when the model's decision has no recognizable rating. It is not # a tradeable position: it flags output that needs a human/re-run rather than # silently degrading to Hold. Callers that map the signal onto the 5-tier enum # (e.g. ``PortfolioRating(signal)``) should guard with ``is_review`` first. 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" # must start a word, so "Operating margin: Sell-side" is not a label. _RATING_LABEL_RE = re.compile(r"(?(?:[-+*\u2022]|\d+[.)])\s+)?[\s*_#]*" + _OWN_QUALIFIER + r"rating[^\w:\-\u2010-\u2015]*[:\-\u2010-\u2015][\s*]*(?P\w+)", re.IGNORECASE, ) # 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) def extract_rating(text: str) -> str | None: """Extract a 5-tier rating from its label, or ``None`` if there is none. Reads an explicit "Rating: X" label (tolerant of markdown bold) in the NFKC-normalized text, so fullwidth punctuation like ``Rating:Overweight`` matches as ASCII does: the decision's opening rating line, else its own rating lines or, failing those, every label, when they agree. """ if not text: return None norm = unicodedata.normalize("NFKC", text) # The decision is asked to open with its rating, so a first line (after any # headings) in that shape is the call. A list item there may also be a quote # heading a list of other parties' ratings, so it counts with the decision's # other rating lines, which leave out list items; failing those, every label # counts. Either way they must agree: ratings that differ, with nothing # marking which one is the call, are no call (#1170). Lines presenting the # scale itself are a legend the model echoed, not a call. lines = [line for line in norm.splitlines() if line.strip() and not _RATING_SCALE_RE.search(line)] first = next((line for line in lines if not line.lstrip().startswith("#") or _RATING_LINE_RE.match(line)), "") m = _RATING_LINE_RE.match(first) opening = m if m and m.group("value").lower() in _RATING_SET else None if opening and not opening.group("item"): return opening.group("value").capitalize() own = [opening.group("value").capitalize()] if opening else [] labels = [] for line in lines: m = _RATING_LINE_RE.match(line) if m and not m.group("item") and m.group("value").lower() in _RATING_SET: own.append(m.group("value").capitalize()) labels += [v.capitalize() for v in _RATING_LABEL_RE.findall(line) if v.lower() in _RATING_SET] # Without a label there is no call to read: a rating word in the prose may be # one the text argues against ("not a Sell"), and reading it reports a # direction nobody decided. found = own or labels return found[0] if found and len(set(found)) == 1 else None def parse_rating(text: str, default: str = RATING_REVIEW) -> str: """Extract a 5-tier rating, or ``REVIEW`` when the decision has none. For callers that need a string for every decision, such as the memory log's entry tag. The default is the review sentinel, never a tradeable rating. """ rating = extract_rating(text) 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