1. Peer percentile is now a tie-aware rank against the OTHER issuers ((worse + 0.5*tied)/(peers-1)): an all-equal group maps to 50 (not 100), the median maps to 50, a unique best to 100, a unique worst to 0. 2. Deterministic reads use the consecutive non-null suffix ending at the latest point (>=3 values): a null latest or an internal gap yields no read, so a read never reflects a period displayed as n/a. 3. Peer filtering excludes non-finite (NaN/±inf) as well as null, including an invalid subject. Tests updated + added (all-equal, median rank, non-finite, latest-null history, internal gap). 15 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
108 lines
3.9 KiB
Python
108 lines
3.9 KiB
Python
"""Pure peer comparison for fundamentals (read-time).
|
||
|
||
Peers are tracked-universe issuers sharing the **first two SIC digits**,
|
||
deduplicated by CIK (GOOG/GOOGL are one issuer, one observation). This module is
|
||
the pure statistics core: given a subject value and the peer group's values for a
|
||
metric, it returns median + polarity-aware favorable percentile + peer_count, or
|
||
None when there are fewer than the minimum valid peers (the caller then omits the
|
||
industry object entirely rather than show a misleading comparison).
|
||
|
||
Grouping (which issuers share a 2-digit SIC, CIK-dedup) is the API's job; this
|
||
module only does the math. **Absolute net_debt is size-dependent and must not get
|
||
a peer percentile** — leverage is compared via net_debt_to_ebitda.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import statistics
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
MIN_PEERS = 5
|
||
|
||
|
||
def _finite(v: Any) -> bool:
|
||
"""True for a finite number — excludes None, bool, NaN, ±inf (plan: null/invalid)."""
|
||
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
||
|
||
# Metric -> is a higher value more favorable? (Peer-eligible metrics only;
|
||
# absolute net_debt is intentionally absent — size-dependent.)
|
||
HIGHER_IS_BETTER: dict[str, bool] = {
|
||
"revenue_growth_yoy": True,
|
||
"eps_growth_yoy": True,
|
||
"operating_margin": True,
|
||
"fcf_margin": True,
|
||
"fcf_yield": True,
|
||
"net_debt_to_ebitda": False, # lower leverage is better
|
||
"pe": False, # cheaper is better
|
||
"share_count_change_yoy": False, # dilution is bad
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class PeerStat:
|
||
median: float
|
||
favorable_percentile: int # 0-100, polarity-aware (higher = more favorable)
|
||
peer_count: int # valid issuers in the group
|
||
|
||
|
||
def peer_stat(
|
||
subject: float | None,
|
||
group_values: list[float | None],
|
||
*,
|
||
higher_is_better: bool,
|
||
min_peers: int = MIN_PEERS,
|
||
) -> PeerStat | None:
|
||
"""Median + favorable percentile for ``subject`` within its group.
|
||
|
||
``group_values`` is every issuer's value for the metric (including the
|
||
subject), CIK-deduplicated by the caller. Null/invalid (non-finite) values are
|
||
excluded. Returns None when fewer than ``min_peers`` valid values exist, or
|
||
the subject is null/invalid.
|
||
|
||
The percentile is a **tie-aware rank against the other issuers** —
|
||
``(worse + 0.5·tied) / (peers − 1)`` — so a whole group of equal values maps
|
||
to 50, not 100, and the median maps to 50.
|
||
"""
|
||
valid = [v for v in group_values if _finite(v)]
|
||
if not _finite(subject) or len(valid) < min_peers:
|
||
return None
|
||
median = statistics.median(valid)
|
||
|
||
others = valid.copy()
|
||
try:
|
||
others.remove(subject) # rank the subject against the OTHER issuers
|
||
except ValueError:
|
||
pass
|
||
denom = len(others)
|
||
if denom == 0:
|
||
return None
|
||
if higher_is_better:
|
||
worse = sum(1 for v in others if v < subject)
|
||
else:
|
||
worse = sum(1 for v in others if v > subject)
|
||
tied = sum(1 for v in others if v == subject)
|
||
percentile = round((worse + 0.5 * tied) / denom * 100)
|
||
return PeerStat(median=median, favorable_percentile=percentile, peer_count=len(valid))
|
||
|
||
|
||
def peer_stat_for(
|
||
metric_key: str, subject: float | None, group_values: list[float | None], **kwargs
|
||
) -> PeerStat | None:
|
||
"""Convenience wrapper that looks up polarity by metric key. Returns None for
|
||
metrics not eligible for peer comparison (e.g. absolute net_debt)."""
|
||
if metric_key not in HIGHER_IS_BETTER:
|
||
return None
|
||
return peer_stat(
|
||
subject, group_values, higher_is_better=HIGHER_IS_BETTER[metric_key], **kwargs
|
||
)
|
||
|
||
|
||
def two_digit_sic(sic: str | None) -> str | None:
|
||
"""The 2-digit SIC prefix used for grouping, or None if unusable."""
|
||
if not sic:
|
||
return None
|
||
digits = str(sic).strip()
|
||
return digits[:2] if len(digits) >= 2 and digits[:2].isdigit() else None
|