fix(fundamentals): A4a review — stricter null semantics in derivation
1. net_debt requires BOTH cash and total_debt; a missing side is null, not treated as zero (which would be a partial, misleading value). 2. net_debt_to_ebitda is null when TTM EBITDA <= 0 — a negative denominator would otherwise rank a distressed issuer as favorably low-leverage. 3. The quarter tape is the CONSECUTIVE run ending at the latest period (stops at a gap), so trend text never compares non-adjacent quarters as if consecutive. 4. YoY growth is null when the prior-year TTM is <= 0 (e.g. loss->profit), which is not a meaningful percentage. Also corrected the plan's net-debt formula to total debt − (cash + ST) matching the positive-means-net-debt implementation. +4 tests. 10 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -84,8 +84,9 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals:
|
|||||||
ttm_capex = _ttm(discrete["capex"], *latest)
|
ttm_capex = _ttm(discrete["capex"], *latest)
|
||||||
result.ttm_fcf = None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
|
result.ttm_fcf = None if ttm_cfo is None or ttm_capex is None else ttm_cfo - ttm_capex
|
||||||
|
|
||||||
# tape = the last TAPE_LEN quarters that have a row, oldest -> newest
|
# tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest,
|
||||||
tape = quarters[-TAPE_LEN:]
|
# stopping at a gap — so trend text never compares non-adjacent periods.
|
||||||
|
tape = _consecutive_suffix(quarters, TAPE_LEN)
|
||||||
result.metrics = {
|
result.metrics = {
|
||||||
"revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
|
"revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], selected, tape),
|
||||||
"eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
|
"eps_growth_yoy": _yoy_growth_series(discrete["diluted_eps"], selected, tape),
|
||||||
@@ -125,6 +126,24 @@ def _ordered_quarters(selected: dict[tuple[int, str], Any]) -> list[tuple[int, i
|
|||||||
return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected)
|
return sorted((fy, _FP_TO_Q[fp]) for (fy, fp) in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _consecutive_suffix(quarters: list[tuple[int, int]], n: int) -> list[tuple[int, int]]:
|
||||||
|
"""The run of up to n quarters ending at the latest, walking back only through
|
||||||
|
adjacent periods (stop at the first gap). Returned oldest -> newest."""
|
||||||
|
if not quarters:
|
||||||
|
return []
|
||||||
|
present = set(quarters)
|
||||||
|
run = [quarters[-1]]
|
||||||
|
cur = quarters[-1]
|
||||||
|
while len(run) < n:
|
||||||
|
prev = _prev_q(*cur)
|
||||||
|
if prev not in present:
|
||||||
|
break
|
||||||
|
run.append(prev)
|
||||||
|
cur = prev
|
||||||
|
run.reverse()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
# -- discrete + TTM ----------------------------------------------------------
|
# -- discrete + TTM ----------------------------------------------------------
|
||||||
|
|
||||||
def _discrete_quarters(selected: dict[tuple[int, str], Any], field_name: str) -> dict[tuple[int, int], float]:
|
def _discrete_quarters(selected: dict[tuple[int, str], Any], field_name: str) -> dict[tuple[int, int], float]:
|
||||||
@@ -162,7 +181,8 @@ def _ttm(dq: dict[tuple[int, int], float], fy: int, q: int) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _pct_change(cur: float | None, prior: float | None) -> float | None:
|
def _pct_change(cur: float | None, prior: float | None) -> float | None:
|
||||||
if cur is None or prior is None or prior == 0:
|
# A non-positive prior makes a YoY % meaningless (e.g. loss->profit), so null it.
|
||||||
|
if cur is None or prior is None or prior <= 0:
|
||||||
return None
|
return None
|
||||||
return (cur / prior - 1.0) * 100.0
|
return (cur / prior - 1.0) * 100.0
|
||||||
|
|
||||||
@@ -212,7 +232,9 @@ def _leverage_series(selected, discrete, tape) -> MetricSeries:
|
|||||||
nd = _net_debt(row)
|
nd = _net_debt(row)
|
||||||
op, da = _ttm(discrete["operating_income"], fy, q), _ttm(discrete["depreciation_amortization"], fy, q)
|
op, da = _ttm(discrete["operating_income"], fy, q), _ttm(discrete["depreciation_amortization"], fy, q)
|
||||||
ebitda = None if op is None or da is None else op + da
|
ebitda = None if op is None or da is None else op + da
|
||||||
val = None if nd is None or not ebitda else nd / ebitda
|
# Null when EBITDA <= 0: a negative denominator would flip polarity and a
|
||||||
|
# "lower is better" read would rank a distressed issuer as favorable.
|
||||||
|
val = None if nd is None or ebitda is None or ebitda <= 0 else nd / ebitda
|
||||||
pts.append(MetricPoint(_period_end(selected, fy, q), val))
|
pts.append(MetricPoint(_period_end(selected, fy, q), val))
|
||||||
return _series(pts)
|
return _series(pts)
|
||||||
|
|
||||||
@@ -231,9 +253,11 @@ def _net_debt(row: Any) -> float | None:
|
|||||||
return None
|
return None
|
||||||
cash = getattr(row, "cash_and_st_investments", None)
|
cash = getattr(row, "cash_and_st_investments", None)
|
||||||
debt = getattr(row, "total_debt", None)
|
debt = getattr(row, "total_debt", None)
|
||||||
if cash is None and debt is None:
|
# Require BOTH components — treating a missing side as zero would produce a
|
||||||
|
# partial, misleading value.
|
||||||
|
if cash is None or debt is None:
|
||||||
return None
|
return None
|
||||||
return (debt or 0.0) - (cash or 0.0) # positive = net debt
|
return debt - cash # positive = net debt
|
||||||
|
|
||||||
|
|
||||||
def _shares(row: Any) -> float | None:
|
def _shares(row: Any) -> float | None:
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ that scoring already reads, refreshed daily by step (c) after activation.
|
|||||||
| EPS growth YoY | TTM diluted EPS vs prior TTM | snapshot |
|
| EPS growth YoY | TTM diluted EPS vs prior TTM | snapshot |
|
||||||
| Operating margin + 4q trend | TTM operating income / revenue | snapshot |
|
| Operating margin + 4q trend | TTM operating income / revenue | snapshot |
|
||||||
| FCF margin | (TTM CFO − capex) / revenue | snapshot |
|
| FCF margin | (TTM CFO − capex) / revenue | snapshot |
|
||||||
| Net cash / net debt | cash + ST investments − total debt | snapshot |
|
| Net debt | total debt − (cash + ST investments); positive = net debt | snapshot |
|
||||||
| Net debt / EBITDA | net debt / TTM EBITDA | snapshot |
|
| Net debt / EBITDA | net debt / TTM EBITDA | snapshot |
|
||||||
| Share count Δ YoY | shares outstanding vs year ago | snapshot |
|
| Share count Δ YoY | shares outstanding vs year ago | snapshot |
|
||||||
| Trailing P/E | price / TTM diluted EPS | request time |
|
| Trailing P/E | price / TTM diluted EPS | request time |
|
||||||
|
|||||||
@@ -123,6 +123,43 @@ def test_missing_period_yields_null_never_partial():
|
|||||||
assert d.ttm_diluted_eps is None
|
assert d.ttm_diluted_eps is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_net_debt_requires_both_components():
|
||||||
|
rows = _two_years()
|
||||||
|
for r in rows: # drop debt on the latest year -> can't form net debt
|
||||||
|
if r.fiscal_year == 2026:
|
||||||
|
r.total_debt = None
|
||||||
|
d = fd.derive(rows)
|
||||||
|
assert d.metrics["net_debt"].value is None
|
||||||
|
assert d.metrics["net_debt_to_ebitda"].value is None # net debt null -> leverage null
|
||||||
|
|
||||||
|
|
||||||
|
def test_leverage_null_when_ebitda_nonpositive():
|
||||||
|
rows = _two_years()
|
||||||
|
for r in rows: # negative operating income -> TTM EBITDA <= 0
|
||||||
|
r.operating_income = -abs(r.revenue)
|
||||||
|
r.depreciation_amortization = 1
|
||||||
|
d = fd.derive(rows)
|
||||||
|
assert d.metrics["net_debt"].value == pytest.approx(100.0) # net debt still valid
|
||||||
|
assert d.metrics["net_debt_to_ebitda"].value is None # but leverage nulled
|
||||||
|
|
||||||
|
|
||||||
|
def test_tape_stops_at_a_gap():
|
||||||
|
rows = [r for r in _two_years() if not (r.fiscal_year == 2026 and r.fiscal_period == "Q1")]
|
||||||
|
d = fd.derive(rows)
|
||||||
|
hist = d.metrics["operating_margin"].history
|
||||||
|
# consecutive suffix ending at FY2026: Q2, Q3, FY (not compressed across the Q1 gap)
|
||||||
|
assert [p.period_end for p in hist] == [date(2026, 3, 31), date(2026, 6, 30), date(2026, 9, 30)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_yoy_growth_null_when_prior_nonpositive():
|
||||||
|
rows = _two_years()
|
||||||
|
for r in rows: # prior-year TTM EPS becomes negative
|
||||||
|
if r.fiscal_year == 2025:
|
||||||
|
r.diluted_eps = -abs(r.diluted_eps)
|
||||||
|
d = fd.derive(rows)
|
||||||
|
assert d.metrics["eps_growth_yoy"].value is None # loss->profit is not a %
|
||||||
|
|
||||||
|
|
||||||
def test_amendment_selection_newest_accepted_wins():
|
def test_amendment_selection_newest_accepted_wins():
|
||||||
rows = _two_years()
|
rows = _two_years()
|
||||||
# an amendment to FY2026 FY restates revenue YTD higher, accepted later
|
# an amendment to FY2026 FY restates revenue YTD higher, accepted later
|
||||||
|
|||||||
Reference in New Issue
Block a user