Completes the pure read-time core. fundamentals_peers.py: median + polarity-aware favorable percentile + peer_count for a subject within its SIC group (CIK-deduped by the caller); returns None below MIN_PEERS=5 so the caller omits the industry object. Absolute net_debt is intentionally NOT peer-eligible (size-dependent) — leverage compares via net_debt_to_ebitda. HIGHER_IS_BETTER polarity map + two_digit_sic() grouping key. fundamentals_reads.py: one shared deterministic rule set (no LLM): growth_read (+-2pp), margin_read (latest vs mean-of-prior, +-1pp), share_count_read (+-1%), peer_read (60/40 bands, polarity-aware phrasing per metric), header_sentence (growth · margins · valuation, omitting empty). Tunable named constants; >=3 periods required for a series read. Tests: 8 peer + 5 reads, anchored on the boundary cases (exactly +2.0pp, exactly 60th percentile, exactly +1.0pp margin). 13 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
"""Deterministic text 'reads' for the fundamentals panel (pure, one rule set).
|
||
|
||
The tape reads and the header sentence use identical outputs — no LLM, no new
|
||
composite score. Thresholds are tunable named constants, not scattered literals
|
||
(plan: ±2pp growth, ±1pp margins, ±1% dilution, 60/40 peer bands, ≥3 periods).
|
||
|
||
Consumers pass metric series (value + dated history, from
|
||
``fundamentals_derivation``) and peer percentiles; these functions return short
|
||
strings or None (render "—", no read).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from statistics import mean
|
||
from typing import Any
|
||
|
||
MIN_PERIODS = 3
|
||
GROWTH_ACCEL_PP = 2.0
|
||
MARGIN_MOVE_PP = 1.0
|
||
SHARE_DILUTION_PCT = 1.0
|
||
PEER_FAVORABLE = 60
|
||
PEER_ADVERSE = 40
|
||
|
||
|
||
def _history_values(history: list[Any]) -> list[float]:
|
||
return [p.value for p in history if p.value is not None]
|
||
|
||
|
||
def growth_read(history: list[Any]) -> str | None:
|
||
"""Change in a YoY-growth series: latest − prior. Needs >= 3 periods."""
|
||
vals = _history_values(history)
|
||
if len(vals) < MIN_PERIODS:
|
||
return None
|
||
delta = vals[-1] - vals[-2]
|
||
if delta >= GROWTH_ACCEL_PP:
|
||
return "accelerating"
|
||
if delta <= -GROWTH_ACCEL_PP:
|
||
return "decelerating"
|
||
return "steady"
|
||
|
||
|
||
def margin_read(history: list[Any]) -> str | None:
|
||
"""Latest margin vs the mean of prior periods (pp). Needs >= 3 periods."""
|
||
vals = _history_values(history)
|
||
if len(vals) < MIN_PERIODS:
|
||
return None
|
||
delta = vals[-1] - mean(vals[:-1])
|
||
if delta >= MARGIN_MOVE_PP:
|
||
return "improving"
|
||
if delta <= -MARGIN_MOVE_PP:
|
||
return "deteriorating"
|
||
return "stable"
|
||
|
||
|
||
def share_count_read(value: float | None) -> str | None:
|
||
"""Share-count YoY %: >+1% dilution, <-1% buying back, else flat."""
|
||
if value is None:
|
||
return None
|
||
if value > SHARE_DILUTION_PCT:
|
||
return f"{value:.1f}% dilution"
|
||
if value < -SHARE_DILUTION_PCT:
|
||
return "buying back"
|
||
return "flat"
|
||
|
||
|
||
def peer_read(metric_key: str, favorable_percentile: int | None) -> str | None:
|
||
"""Peer-relative read for a metric, polarity already baked into the
|
||
percentile (higher = more favorable)."""
|
||
if favorable_percentile is None:
|
||
return None
|
||
if favorable_percentile >= PEER_FAVORABLE:
|
||
return _FAVORABLE.get(metric_key, "above peers")
|
||
if favorable_percentile <= PEER_ADVERSE:
|
||
return _ADVERSE.get(metric_key, "below peers")
|
||
return "in line"
|
||
|
||
|
||
_FAVORABLE = {
|
||
"pe": "attractively valued",
|
||
"fcf_yield": "above peers",
|
||
"net_debt_to_ebitda": "conservative leverage",
|
||
}
|
||
_ADVERSE = {
|
||
"pe": "priced above peers",
|
||
"fcf_yield": "below peers",
|
||
"net_debt_to_ebitda": "elevated leverage",
|
||
}
|
||
|
||
|
||
def header_sentence(
|
||
growth: str | None, margin: str | None, valuation: str | None
|
||
) -> str:
|
||
"""Join the growth / margin / peer-valuation reads with ' · ', omitting
|
||
segments with no read. Segment sources are fixed by the caller (growth =
|
||
revenue-growth read, margin = operating-margin read, valuation = P/E peer
|
||
read falling back to FCF yield)."""
|
||
parts = []
|
||
if growth:
|
||
parts.append(f"growth {growth}")
|
||
if margin:
|
||
parts.append(f"margins {margin}")
|
||
if valuation:
|
||
parts.append(f"valuation {valuation}")
|
||
return " · ".join(parts)
|