Source code for trader.discovery.health_score

"""A composite analyst "health score" (0-100), reimplemented in `Decimal`
from `../stock-screener`'s `calculate_health_score` (issue #39).

Scored purely from data this app already fetches and stores: `AnalystOpinion`
(the five recommendation counts plus, since issue #27, `recommendation_mean`/
`target_mean_price`) and a current price the caller already holds — no new
provider, no new fetch policy. `calculate_health_score` is a pure function;
it does not reach for a price or an opinion itself.

A scoring/screening tool, not a trading signal by itself: nothing here is
wired into `discovery/filters.py`'s gates, `LlmStrategy`, or any other
order-affecting path.
"""

from dataclasses import dataclass
from decimal import Decimal

from trader.marketdata.analysts import AnalystOpinion
from trader.reporting.returns import PERCENT_QUANTUM

__all__ = [
    "TIER_CONCERNING",
    "TIER_EXCELLENT",
    "TIER_FAIR",
    "TIER_GOOD",
    "HealthScoreResult",
    "calculate_health_score",
]

# --- Score buckets (source: `calculate_health_score`'s own thresholds) ------
TIER_EXCELLENT = "Excellent"
TIER_GOOD = "Good"
TIER_FAIR = "Fair"
TIER_CONCERNING = "Concerning"

_TIER_EXCELLENT_MIN = Decimal(80)
_TIER_GOOD_MIN = Decimal(65)
_TIER_FAIR_MIN = Decimal(50)

# --- The formula's own constants, named rather than inlined -----------------
_BASE_SCORE = Decimal(50)
_STRONG_BUY_WEIGHT = Decimal(20)
_BUY_WEIGHT = Decimal(10)

_UPSIDE_TIER_1_THRESHOLD = Decimal(30)
_UPSIDE_TIER_1_BONUS = Decimal(20)
_UPSIDE_TIER_2_THRESHOLD = Decimal(15)
_UPSIDE_TIER_2_BONUS = Decimal(15)
_UPSIDE_TIER_3_THRESHOLD = Decimal(5)
_UPSIDE_TIER_3_BONUS = Decimal(10)
_UPSIDE_TIER_4_THRESHOLD = Decimal(-10)
_UPSIDE_TIER_4_PENALTY = Decimal(-15)

_COVERAGE_BONUS_CAP = Decimal(10)

_SCORE_MIN = Decimal(0)
_SCORE_MAX = Decimal(100)
_HUNDRED = Decimal(100)

# --- The source's buy-candidate / review-flag screens, kept the same shape --
_BUY_CANDIDATE_UPSIDE_THRESHOLD = Decimal(15)
_BUY_CANDIDATE_COMBINED_BUY_PCT_THRESHOLD = Decimal(60)
_BUY_CANDIDATE_SCORE_THRESHOLD = Decimal(65)
_REVIEW_FLAG_SCORE_THRESHOLD = Decimal(55)
_REVIEW_FLAG_UPSIDE_THRESHOLD = Decimal(-5)


[docs] @dataclass(frozen=True, slots=True) class HealthScoreResult: """One symbol's composite health score, at one point in time. `score` is rounded to `PERCENT_QUANTUM` (2dp) for display, but every comparison below — the tier bucket and the two screen flags — is decided from the exact, unrounded value first, so a score that lands squarely on a boundary is never misclassified by its own rounding. `upside_pct` is `None` when `target_mean_price` is absent (or the current price is not strictly positive) — a missing target is not the same claim as zero upside, so the target-upside tiers contribute no bonus/penalty rather than reading `None` as 0. """ score: Decimal tier: str upside_pct: Decimal | None total_analysts: int is_buy_candidate: bool is_review_flag: bool
def _tier_for(score: Decimal) -> str: if score >= _TIER_EXCELLENT_MIN: return TIER_EXCELLENT if score >= _TIER_GOOD_MIN: return TIER_GOOD if score >= _TIER_FAIR_MIN: return TIER_FAIR return TIER_CONCERNING
[docs] def calculate_health_score( opinion: AnalystOpinion | None, current_price: Decimal ) -> HealthScoreResult | None: """The 0-100 composite score, or `None` when there is no coverage. `opinion is None` — no analysts cover the symbol, ETFs mainly — is a distinct claim from an `AnalystOpinion` whose five counts are all zero (which can legitimately reach here from a persisted row). Handled explicitly rather than by falling through arithmetic that would treat a missing opinion as zero counts, the same discipline `opinion_gate` already applies in `discovery/filters.py` (CLAUDE.md, "Missing analyst coverage passes, and is not the same as zero"). """ if opinion is None: return None total_analysts = ( opinion.strong_buy + opinion.buy + opinion.hold + opinion.sell + opinion.strong_sell ) score = _BASE_SCORE if total_analysts > 0: strong_buy_pct = (Decimal(opinion.strong_buy) / total_analysts) * _HUNDRED buy_pct = (Decimal(opinion.buy) / total_analysts) * _HUNDRED score += (strong_buy_pct / _HUNDRED) * _STRONG_BUY_WEIGHT score += (buy_pct / _HUNDRED) * _BUY_WEIGHT else: # Zero analysts have an opinion at all — sentiment contributes # nothing, positive or negative. Distinct from `opinion is None` # above: this is a persisted opinion whose counts happen to sum to # zero, not an absence of coverage. strong_buy_pct = Decimal(0) buy_pct = Decimal(0) upside_pct: Decimal | None = None if opinion.target_mean_price is not None and current_price > 0: upside_pct = ( (opinion.target_mean_price - current_price) / current_price ) * _HUNDRED if upside_pct > _UPSIDE_TIER_1_THRESHOLD: score += _UPSIDE_TIER_1_BONUS elif upside_pct > _UPSIDE_TIER_2_THRESHOLD: score += _UPSIDE_TIER_2_BONUS elif upside_pct > _UPSIDE_TIER_3_THRESHOLD: score += _UPSIDE_TIER_3_BONUS elif upside_pct < _UPSIDE_TIER_4_THRESHOLD: score += _UPSIDE_TIER_4_PENALTY # Between -10 (inclusive) and 5 (inclusive): no bonus, no penalty — # the formula's own dead zone, preserved as-is from the source. score += min(_COVERAGE_BONUS_CAP, Decimal(total_analysts)) score = max(_SCORE_MIN, min(_SCORE_MAX, score)) tier = _tier_for(score) combined_buy_pct = strong_buy_pct + buy_pct is_buy_candidate = ( upside_pct is not None and upside_pct > _BUY_CANDIDATE_UPSIDE_THRESHOLD and combined_buy_pct > _BUY_CANDIDATE_COMBINED_BUY_PCT_THRESHOLD and score >= _BUY_CANDIDATE_SCORE_THRESHOLD ) is_review_flag = score < _REVIEW_FLAG_SCORE_THRESHOLD or ( upside_pct is not None and upside_pct < _REVIEW_FLAG_UPSIDE_THRESHOLD ) return HealthScoreResult( score=score.quantize(PERCENT_QUANTUM), tier=tier, upside_pct=upside_pct.quantize(PERCENT_QUANTUM) if upside_pct is not None else None, total_analysts=total_analysts, is_buy_candidate=is_buy_candidate, is_review_flag=is_review_flag, )