fix(sec): A3 slice-2a review — shares fallback, robust context, hardening
1. Multi-class shares: prefer the single dei:EntityCommonStockSharesOutstanding cover-page fact; else fall back to us-gaap:CommonStockSharesOutstanding at period end (Alphabet has no dei fact). Never sum class facts (companyfacts is non-dimensional) and never use weighted-average/diluted; conflicting values -> null, counted as an "ambiguous shares outstanding" note in validation. Plan's "sum class-specific" wording corrected. Verified live: Alphabet shares now populate (12.1B), Apple still uses its dei cover date. 2. Fiscal context is the majority (fy, fp) among facts ending at reportDate, with ties rejected — no longer the arbitrary first fact. 3. Hardening: catalog selectors require taxonomy == "us-gaap"; indexing drops malformed facts (missing accession/end, non-finite value) so a custom concept or bad date can't be selected. Tests: +8 (dei precedence, us-gaap fallback, conflict->null, no weighted-average, tie-context skip, foreign-taxonomy/malformed ignored, ambiguous-shares note). 14 passed, 1 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ from app.services.sec_facts_parser import (
|
||||
FilingMeta,
|
||||
_compose_cash,
|
||||
_compose_debt,
|
||||
_fiscal_context,
|
||||
_select_shares,
|
||||
parse_snapshots,
|
||||
)
|
||||
|
||||
@@ -135,6 +137,95 @@ def test_cash_picks_one_st_investment_source():
|
||||
assert _compose_cash(facts, rd) == 45000
|
||||
|
||||
|
||||
RD = date(2026, 3, 28)
|
||||
|
||||
|
||||
def _dei(end, val):
|
||||
return Fact("dei", "EntityCommonStockSharesOutstanding", "shares", None, end, val, 2026, "Q2")
|
||||
|
||||
|
||||
def _gaap_shares(end, val):
|
||||
return Fact("us-gaap", "CommonStockSharesOutstanding", "shares", None, end, val, 2026, "Q2")
|
||||
|
||||
|
||||
def test_shares_prefers_dei_cover_page():
|
||||
facts = [_dei(date(2026, 4, 17), 100), _gaap_shares(RD, 999)]
|
||||
assert _select_shares(facts, RD) == (100.0, date(2026, 4, 17), False)
|
||||
|
||||
|
||||
def test_shares_falls_back_to_usgaap_at_report_date():
|
||||
# Alphabet case: no dei fact; us-gaap current + a prior comparative.
|
||||
facts = [_gaap_shares(date(2025, 12, 31), 888), _gaap_shares(RD, 12116)]
|
||||
assert _select_shares(facts, RD) == (12116.0, RD, False) # comparative excluded
|
||||
|
||||
|
||||
def test_shares_conflict_returns_null_ambiguous():
|
||||
facts = [_dei(RD, 100), _dei(RD, 200)] # two differing consolidated values
|
||||
assert _select_shares(facts, RD) == (None, None, True)
|
||||
|
||||
|
||||
def test_shares_never_uses_weighted_average():
|
||||
facts = [Fact("us-gaap", "WeightedAverageNumberOfDilutedSharesOutstanding", "shares", None, RD, 5, 2026, "Q2")]
|
||||
assert _select_shares(facts, RD) == (None, None, False) # not a shares source
|
||||
|
||||
|
||||
def test_conflicting_fiscal_context_is_rejected():
|
||||
facts = [
|
||||
Fact("us-gaap", "Revenues", "USD", date(2025, 9, 28), RD, 1, 2026, "Q2"),
|
||||
Fact("us-gaap", "NetIncomeLoss", "USD", date(2025, 9, 28), RD, 2, 2025, "Q3"),
|
||||
] # 1-1 tie between two contexts at reportDate
|
||||
assert _fiscal_context(facts, RD) == (None, None)
|
||||
# a clear majority wins
|
||||
facts.append(Fact("us-gaap", "OperatingIncomeLoss", "USD", date(2025, 9, 28), RD, 3, 2026, "Q2"))
|
||||
assert _fiscal_context(facts, RD) == (2026, "Q2")
|
||||
|
||||
|
||||
def test_conflicting_context_skips_row():
|
||||
cf = {"cik": 1, "facts": {"us-gaap": {
|
||||
"Revenues": {"units": {"USD": [_dur("2025-09-28", "2026-03-28", 1, "B", fp="Q2")]}},
|
||||
"NetIncomeLoss": {"units": {"USD": [_dur("2025-09-28", "2026-03-28", 2, "B", fy=2025, fp="Q3")]}},
|
||||
}}}
|
||||
filings = {"B": FilingMeta(RD, date(2026, 5, 1), datetime(2026, 5, 1, tzinfo=UTC), "10-Q")}
|
||||
rows, skips = parse_snapshots(cf, filings, {"B"})
|
||||
assert rows == [] and skips == [{"accession": "B", "reason": "no usable period identity"}]
|
||||
|
||||
|
||||
def test_foreign_taxonomy_and_malformed_facts_ignored():
|
||||
cf = {"cik": 1, "facts": {
|
||||
"us-gaap": {
|
||||
"Revenues": {"units": {"USD": [_dur("2025-09-28", "2026-03-28", 500, "B")]}},
|
||||
"NetIncomeLoss": {"units": {"USD": [
|
||||
{"start": "2025-09-28", "end": "2026-03-28", "val": None, "fy": 2026, "fp": "Q2", "accn": "B"},
|
||||
]}},
|
||||
"OperatingIncomeLoss": {"units": {"USD": [
|
||||
{"start": "2025-09-28", "end": "2026-03-28", "val": float("nan"), "fy": 2026, "fp": "Q2", "accn": "B"},
|
||||
]}},
|
||||
},
|
||||
"acme": {"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
|
||||
_dur("2025-09-28", "2026-03-28", 99999, "B"), # custom taxonomy — must be ignored
|
||||
]}}},
|
||||
}}
|
||||
filings = {"B": FilingMeta(RD, date(2026, 5, 1), datetime(2026, 5, 1, tzinfo=UTC), "10-Q")}
|
||||
rows, _ = parse_snapshots(cf, filings, {"B"})
|
||||
assert rows[0].revenue == 500 # us-gaap Revenues, not the acme concept
|
||||
assert rows[0].net_income is None # val None ignored
|
||||
assert rows[0].operating_income is None # NaN ignored
|
||||
|
||||
|
||||
def test_ambiguous_shares_produces_row_plus_note():
|
||||
cf = {"cik": 1, "facts": {
|
||||
"us-gaap": {"Revenues": {"units": {"USD": [_dur("2025-09-28", "2026-03-28", 500, "B")]}}},
|
||||
"dei": {"EntityCommonStockSharesOutstanding": {"units": {"shares": [
|
||||
{"end": "2026-04-17", "val": 100, "fy": 2026, "fp": "Q2", "accn": "B"},
|
||||
{"end": "2026-04-17", "val": 200, "fy": 2026, "fp": "Q2", "accn": "B"},
|
||||
]}}},
|
||||
}}
|
||||
filings = {"B": FilingMeta(RD, date(2026, 5, 1), datetime(2026, 5, 1, tzinfo=UTC), "10-Q")}
|
||||
rows, skips = parse_snapshots(cf, filings, {"B"})
|
||||
assert len(rows) == 1 and rows[0].shares_outstanding is None # row kept, shares null
|
||||
assert {"accession": "B", "reason": "ambiguous shares outstanding"} in skips
|
||||
|
||||
|
||||
# Opt-in live check against real Apple companyfacts. Skips unless SEC_LIVE=1 and a
|
||||
# real SEC_USER_AGENT are set (network + fair-access contact email).
|
||||
@pytest.mark.skipif(
|
||||
|
||||
Reference in New Issue
Block a user