fix(sec): keep the market-cap fallback through an amendment merge

Found in review. _merge_amendments rebuilds a period from _MERGED_FIELDS +
_CARRIED_FIELDS alone, so a column in neither list is absent from the merged
row, not just stale — and callers read it with getattr(..., None), which
silently yields None. weighted_avg_diluted_shares was never added when the
market-cap fallback landed (_SNAPSHOT_COLS in the importer was updated, its
counterpart in the derivation was not).

The failure needed both of this branch's fixes at once: a multi-class issuer
with a partial amendment on its latest period (META with a Part-III-only
10-K/A) would silently lose market cap and FCF yield again.

Adds the field, a regression test for that case, and a guard test asserting
the merge/carry lists cover every SnapshotRow field, so the next column added
fails loudly rather than losing data quietly. Confirmed the guard catches the
original bug.

Also from review:
- Expose pe_caveat in the valuation payload, so a P/E suppressed by split
  contamination says why instead of looking like missing data (the caveat was
  set but never read).
- no_xbrl_filings now names both causes; the old text advised pinning a CIK
  override, which is wrong for a genuine new registrant that simply has not
  filed yet and clears itself.
- Document that fiscalYearEnd is the issuer's current calendar, so a fiscal-
  year-end change degrades old periods (fallback/newest-wins), not current ones.
- Parser-level tests for _select_weighted_avg_shares (shortest-span-wins and
  concept priority), which only had derivation-level coverage.

823 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:50:17 +02:00
co-authored by Claude Opus 4.8
parent fae621475b
commit 0e556d8a43
7 changed files with 102 additions and 3 deletions
@@ -291,6 +291,20 @@ def test_point_in_time_share_count_is_preferred_and_not_flagged():
assert d.shares_outstanding_estimated is False
def test_weighted_average_fallback_survives_a_partial_amendment():
# A Part-III-only 10-K/A on a multi-class issuer's latest period: the merged
# row must keep the weighted-average count, or market cap silently vanishes.
rows = _two_years()
for row in rows:
row.shares_outstanding = None
row.weighted_avg_diluted_shares = 2_564_000_000.0
part_iii_only = Snap(2026, "FY", date(2026, 9, 30), date(2026, 11, 1),
datetime(2027, 1, 1, tzinfo=UTC))
d = fd.derive(rows + [part_iii_only])
assert d.shares_outstanding == 2_564_000_000.0
assert d.shares_outstanding_estimated is True
def test_no_share_count_at_all_stays_none_and_unflagged():
rows = _two_years()
for row in rows:
@@ -298,3 +312,22 @@ def test_no_share_count_at_all_stays_none_and_unflagged():
d = fd.derive(rows)
assert d.shares_outstanding is None
assert d.shares_outstanding_estimated is False
def test_merge_lists_cover_every_parser_field():
"""_MERGED_FIELDS/_CARRIED_FIELDS are hand-maintained, and _merge_amendments
builds the merged row from them alone — so a parser field missing from both
is not merely stale on a merged period, it is *absent*, and callers using
getattr(row, name, None) read None. That is how weighted_avg_diluted_shares
silently lost market cap for multi-class issuers with a partial amendment.
Adding a column to SnapshotRow must fail here rather than lose data quietly.
"""
import dataclasses
from app.services.sec_facts_parser import SnapshotRow
parser_fields = {f.name for f in dataclasses.fields(SnapshotRow)}
covered = set(fd._MERGED_FIELDS) | set(fd._CARRIED_FIELDS)
assert not parser_fields - covered, (
f"parser fields not merged or carried: {sorted(parser_fields - covered)}"
)
+31
View File
@@ -463,3 +463,34 @@ def test_diluted_still_wins_over_basic_when_both_present():
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].diluted_eps == 1.36
def test_weighted_average_shares_prefers_the_shortest_span():
# A 10-Q carries both the quarter's average and the YTD one. The shorter
# window sits closer to the current count, which is what market cap wants.
companyfacts = {
"cik": 1326801,
"facts": {"us-gaap": {"WeightedAverageNumberOfDilutedSharesOutstanding": {
"units": {"shares": [
_dur("2026-01-01", "2026-09-30", 2_600_000_000, "X", fp="Q3"), # YTD
_dur("2026-07-01", "2026-09-30", 2_564_000_000, "X", fp="Q3"), # quarter
]}
}}},
}
filings = {"X": FilingMeta(date(2026, 9, 30), date(2026, 11, 1),
datetime(2026, 11, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].weighted_avg_diluted_shares == 2_564_000_000
def test_weighted_average_shares_falls_back_to_the_basic_and_diluted_concept():
companyfacts = {
"cik": 1326801,
"facts": {"us-gaap": {"WeightedAverageNumberOfSharesOutstandingBasicAndDiluted": {
"units": {"shares": [_dur("2026-07-01", "2026-09-30", 500_000, "X", fp="Q3")]}
}}},
}
filings = {"X": FilingMeta(date(2026, 9, 30), date(2026, 11, 1),
datetime(2026, 11, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].weighted_avg_diluted_shares == 500_000