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,
)