fix(sec): derive fiscal year end from the issuer's own 10-K

Regression found by the post-reparse collision check. _period_identity trusted
submissions.fiscalYearEnd, which is not reliable: Franklin Resources (BEN)
declares 1231 while every one of its 10-Ks ends 09-30.

The effect was data loss, not just a bad label. BEN's real fiscal Q1 (Dec 31)
sat 0 days from the claimed year end, matching no quarter band, so it fell back
to SEC's fy/fp; its fiscal Q2 (Mar 31) computed 275 days out and was labelled
Q1. Both landed on the same key, the collision discarded one, and BEN lost TTM
EPS and revenue growth entirely — values it had before this branch.

A 10-K's reportDate IS the fiscal year end by definition, so resolve_fiscal_
year_end() now prefers the issuer's most recent annual filing and treats the
declared value as a fallback for issuers with no 10-K in the set.

Scanned the full tracked universe: 2 of 506 issuers declare a year end more
than 21 days from their own 10-K — BEN (91d, broken) and DELL (29d, mislabelled
but functionally correct). Both now derive correctly and match the legacy
provider: BEN revenue growth 3.8243 vs 3.82, DELL 38.5735 vs 38.57. Controls
(AAPL, COST, PEP, DPZ, IRM, JPM, CRM, STX, AVY) byte-identical.

826 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 11:59:15 +02:00
co-authored by Claude Opus 4.8
parent 0e556d8a43
commit 3d42ca7241
2 changed files with 74 additions and 3 deletions
+30 -3
View File
@@ -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]:
+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")}
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")}