feat(fundamentals): A4 — pure peer comparison + deterministic reads
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>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user