Files
signal-platform/app/services/fundamentals_reads.py
T
dennisthiessenandClaude Opus 4.8 2038b84b72 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>
2026-07-22 20:25:48 +02:00

105 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)