fix(fundamentals): pure-core review — tie-aware percentile, null-safe reads
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>
This commit is contained in:
@@ -14,11 +14,18 @@ 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] = {
|
||||
@@ -50,18 +57,33 @@ def peer_stat(
|
||||
"""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. Nulls are excluded. Returns None
|
||||
when fewer than ``min_peers`` valid values exist, or the subject is null.
|
||||
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 v is not None]
|
||||
if subject is None or len(valid) < min_peers:
|
||||
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:
|
||||
favorable = sum(1 for v in valid if v <= subject)
|
||||
worse = sum(1 for v in others if v < subject)
|
||||
else:
|
||||
favorable = sum(1 for v in valid if v >= subject)
|
||||
percentile = round(favorable / len(valid) * 100)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -22,13 +22,23 @@ 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 _latest_run(history: list[Any]) -> list[float]:
|
||||
"""The consecutive non-null values ending at the latest point (oldest->newest).
|
||||
A null latest, or an internal gap, truncates the run — so a read never reflects
|
||||
a period whose displayed value is n/a."""
|
||||
run: list[float] = []
|
||||
for p in reversed(history):
|
||||
if p.value is None:
|
||||
break
|
||||
run.append(p.value)
|
||||
run.reverse()
|
||||
return run
|
||||
|
||||
|
||||
def growth_read(history: list[Any]) -> str | None:
|
||||
"""Change in a YoY-growth series: latest − prior. Needs >= 3 periods."""
|
||||
vals = _history_values(history)
|
||||
"""Change in a YoY-growth series: latest − prior. Needs >= 3 consecutive
|
||||
non-null values ending at the latest point."""
|
||||
vals = _latest_run(history)
|
||||
if len(vals) < MIN_PERIODS:
|
||||
return None
|
||||
delta = vals[-1] - vals[-2]
|
||||
@@ -40,8 +50,9 @@ def growth_read(history: list[Any]) -> str | None:
|
||||
|
||||
|
||||
def margin_read(history: list[Any]) -> str | None:
|
||||
"""Latest margin vs the mean of prior periods (pp). Needs >= 3 periods."""
|
||||
vals = _history_values(history)
|
||||
"""Latest margin vs the mean of prior periods (pp). Needs >= 3 consecutive
|
||||
non-null values ending at the latest point."""
|
||||
vals = _latest_run(history)
|
||||
if len(vals) < MIN_PERIODS:
|
||||
return None
|
||||
delta = vals[-1] - mean(vals[:-1])
|
||||
|
||||
Reference in New Issue
Block a user