Merge pull request 'fix(sec): derive fiscal year end from the issuer's own 10-K' (#3) from fix/sec-fundamentals-parity-gaps into main
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m49s
Deploy / deploy (push) Successful in 1m0s

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-24 12:01:20 +02:00
2 changed files with 74 additions and 3 deletions
+30 -3
View File
@@ -179,15 +179,19 @@ def parse_snapshots(
) -> ParseResult: ) -> ParseResult:
"""Build snapshot rows for ``accessions`` (those with facts + filing meta). """Build snapshot rows for ``accessions`` (those with facts + filing meta).
``fiscal_year_end`` is the issuer's ``submissions.fiscalYearEnd`` (MMDD) and ``fiscal_year_end`` is the issuer's declared ``submissions.fiscalYearEnd``
is what makes period identity independent of SEC's unreliable fy/fp fields (MMDD) and seeds period identity (see ``_period_identity``), making it
(see ``_period_identity``). Omitting it falls back to the old fy/fp behaviour. 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 ``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. identity); ``field_issues`` = a row was produced but a field is null/ambiguous.
Callers must not use field issues as failed-row coverage. Callers must not use field issues as failed-row coverage.
""" """
cik = f"{int(companyfacts['cik']):010d}" 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) by_accn = _index_by_accession(companyfacts)
result = ParseResult() result = ParseResult()
for accn in accessions: for accn in accessions:
@@ -291,6 +295,29 @@ def _parse_one(
return row, ("ambiguous shares outstanding" if ambiguous else None) 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( def _period_identity(
meta: FilingMeta, fiscal_year_end: str | None meta: FilingMeta, fiscal_year_end: str | None
) -> tuple[int | None, str | None]: ) -> tuple[int | None, str | None]:
+44
View File
@@ -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")} datetime(2026, 11, 1, 10, tzinfo=UTC), "10-Q")}
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231") res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
assert res.rows[0].weighted_avg_diluted_shares == 500_000 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")}