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:
@@ -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]:
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user