diff --git a/app/services/sec_facts_parser.py b/app/services/sec_facts_parser.py index 6a6e8f9..ae90258 100644 --- a/app/services/sec_facts_parser.py +++ b/app/services/sec_facts_parser.py @@ -179,15 +179,19 @@ def parse_snapshots( ) -> ParseResult: """Build snapshot rows for ``accessions`` (those with facts + filing meta). - ``fiscal_year_end`` is the issuer's ``submissions.fiscalYearEnd`` (MMDD) and - is what makes period identity independent of SEC's unreliable fy/fp fields - (see ``_period_identity``). Omitting it falls back to the old fy/fp behaviour. + ``fiscal_year_end`` is the issuer's declared ``submissions.fiscalYearEnd`` + (MMDD) and seeds period identity (see ``_period_identity``), making it + independent of SEC's unreliable fy/fp fields. It is only a hint: the issuer's + own 10-K period ends override it (see ``resolve_fiscal_year_end``). With + neither available, the old fy/fp behaviour is used. ``skipped_filings`` = no row produced (missing facts/meta or no usable period identity); ``field_issues`` = a row was produced but a field is null/ambiguous. Callers must not use field issues as failed-row coverage. """ cik = f"{int(companyfacts['cik']):010d}" + # The declared value is only a hint; the issuer's own 10-Ks are authoritative. + fiscal_year_end = resolve_fiscal_year_end(filings, fiscal_year_end) by_accn = _index_by_accession(companyfacts) result = ParseResult() for accn in accessions: @@ -291,6 +295,29 @@ def _parse_one( return row, ("ambiguous shares outstanding" if ambiguous else None) +def resolve_fiscal_year_end( + filings: dict[str, FilingMeta], declared: str | None +) -> str | None: + """The issuer's fiscal-year-end MMDD, preferring its own 10-K period ends. + + ``submissions.fiscalYearEnd`` is *not* reliable: Franklin Resources (BEN) + declares 1231 while every one of its 10-Ks ends 09-30. Trusting it put BEN's + fiscal Q1 (Dec) 0 days from the claimed year end — matching no quarter band — + and labelled its fiscal Q2 (Mar) as Q1, colliding two periods on one key and + destroying the quarter chain. + + A 10-K's reportDate **is** the fiscal year end by definition, so it wins + whenever one is available; the declared value is only a fallback for an issuer + with no annual filing in the set. The most recent 10-K is used, so an issuer + that changed its year end is measured against its current calendar. + """ + annual = [m.report_date for m in filings.values() if m.form.startswith("10-K")] + if annual: + latest = max(annual) + return f"{latest.month:02d}{latest.day:02d}" + return declared + + def _period_identity( meta: FilingMeta, fiscal_year_end: str | None ) -> tuple[int | None, str | None]: diff --git a/tests/unit/test_sec_facts_parser.py b/tests/unit/test_sec_facts_parser.py index 3bc6f6b..569fc8a 100644 --- a/tests/unit/test_sec_facts_parser.py +++ b/tests/unit/test_sec_facts_parser.py @@ -494,3 +494,47 @@ def test_weighted_average_shares_falls_back_to_the_basic_and_diluted_concept(): 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 + + +# -- fiscal year end resolution (submissions.fiscalYearEnd is unreliable) ----- + +from app.services.sec_facts_parser import resolve_fiscal_year_end # noqa: E402 + + +def test_the_issuers_own_10k_overrides_a_wrong_declared_year_end(): + # Franklin Resources declares 1231 while every 10-K ends 09-30. Trusting the + # declaration labelled its fiscal Q2 (Mar) as Q1, colliding with the real + # fiscal Q1 (Dec) and destroying the quarter chain. + filings = { + "K": _meta("2025-09-30", "10-K"), + "Q": _meta("2025-12-31"), + } + assert resolve_fiscal_year_end(filings, "1231") == "0930" + + +def test_declared_year_end_is_used_when_no_annual_filing_is_present(): + assert resolve_fiscal_year_end({"Q": _meta("2026-03-31")}, "1231") == "1231" + assert resolve_fiscal_year_end({}, None) is None + + +def test_a_wrong_declared_year_end_no_longer_collides_two_periods(): + """End to end: BEN's Dec and Mar quarters must land on distinct keys.""" + def _q(accn, start, end, val): + return _dur(start, end, val, accn, fp="Q1") + + companyfacts = { + "cik": 38777, + "facts": {"us-gaap": {"Revenues": {"units": {"USD": [ + _q("Q1", "2025-10-01", "2025-12-31", 2327), # fiscal Q1 + _q("Q2", "2025-10-01", "2026-03-31", 4622), # fiscal Q2 YTD + ]}}}}, + } + filings = { + "K": _meta("2025-09-30", "10-K"), + "Q1": _meta("2025-12-31"), + "Q2": _meta("2026-03-31"), + } + res = parse_snapshots(companyfacts, filings, {"Q1", "Q2"}, fiscal_year_end="1231") + keys = {(r.fiscal_year, r.fiscal_period) for r in res.rows} + assert len(keys) == 2, f"periods collided on one key: {keys}" + assert keys == {(2026, "Q1"), (2026, "Q2")}