From 256880899b39ca64fd46170719fcebbb671cd6e5 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Wed, 22 Jul 2026 20:20:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(fundamentals):=20A4a=20review=20=E2=80=94?= =?UTF-8?q?=20stricter=20null=20semantics=20in=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/services/fundamentals_derivation.py | 36 +++++++++++++++++---- docs/dolt-integration-plan.md | 2 +- tests/unit/test_fundamentals_derivation.py | 37 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/app/services/fundamentals_derivation.py b/app/services/fundamentals_derivation.py index 0f1d42f..e5d203c 100644 --- a/app/services/fundamentals_derivation.py +++ b/app/services/fundamentals_derivation.py @@ -84,8 +84,9 @@ def derive(snapshots: Iterable[Any]) -> DerivedFundamentals: 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 - # tape = the last TAPE_LEN quarters that have a row, oldest -> newest - tape = quarters[-TAPE_LEN:] + # tape = the CONSECUTIVE run of up to TAPE_LEN quarters ending at the latest, + # stopping at a gap — so trend text never compares non-adjacent periods. + tape = _consecutive_suffix(quarters, TAPE_LEN) result.metrics = { "revenue_growth_yoy": _yoy_growth_series(discrete["revenue"], 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) +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 ---------------------------------------------------------- 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: - 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 (cur / prior - 1.0) * 100.0 @@ -212,7 +232,9 @@ def _leverage_series(selected, discrete, tape) -> MetricSeries: nd = _net_debt(row) 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 - 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)) return _series(pts) @@ -231,9 +253,11 @@ def _net_debt(row: Any) -> float | None: return None cash = getattr(row, "cash_and_st_investments", 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 (debt or 0.0) - (cash or 0.0) # positive = net debt + return debt - cash # positive = net debt def _shares(row: Any) -> float | None: diff --git a/docs/dolt-integration-plan.md b/docs/dolt-integration-plan.md index d29e2c0..8fbbcd0 100644 --- a/docs/dolt-integration-plan.md +++ b/docs/dolt-integration-plan.md @@ -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 | | Operating margin + 4q trend | TTM operating income / 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 | | Share count Δ YoY | shares outstanding vs year ago | snapshot | | Trailing P/E | price / TTM diluted EPS | request time | diff --git a/tests/unit/test_fundamentals_derivation.py b/tests/unit/test_fundamentals_derivation.py index f079d35..ca249f4 100644 --- a/tests/unit/test_fundamentals_derivation.py +++ b/tests/unit/test_fundamentals_derivation.py @@ -123,6 +123,43 @@ def test_missing_period_yields_null_never_partial(): 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(): rows = _two_years() # an amendment to FY2026 FY restates revenue YTD higher, accepted later