The A5 parity report surfaced coverage gaps and wrong values that all traced to the SEC facts parser and read-time derivation rather than to bad source data. Fixes, each validated by replaying the production parser + derivation against live company facts: - Period identity is derived from period_end against the issuer's fiscal calendar, not SEC's fy/fp fields, which collide (two period ends on one key, one silently discarded) and invert (a period sorting before one that precedes it) often enough to break the quarter chain. Recovers BXP, CRM, CRWD, FRT, MTD, NTAP, PPL, STX, WDAY. Fixed labels are internal ordering keys only (not in any API schema), so a filer whose year ends in early January shifting by one is harmless. - Revenue concept list gains RevenuesNetOfInterestExpense (banks) and the IncludingAssessedTax variant (REITs/consumer); EPS gains the continuing-ops variant (REG/FCX) and, last, basic EPS for a period tagging no diluted variant at all (PPL). All appended, so any issuer that already resolved keeps its concept. - YTD span tolerance 20 -> 25 days, covering 4-4-5 retail calendars whose 36-week YTD-Q3 (251-252d) previously missed by ~2 (COST, PEP, DPZ). - Amendment resolution is per field: a partial 10-K/A (Part III only, no financial facts) no longer blanks the period (DVN). - TTM diluted EPS is suppressed when a split contaminates the trailing window (BKNG's mixed-unit sum produced a P/E of 1.10 that clamped to a perfect fundamental sub-score). A post-filing split with no share-count evidence (KLAC) remains undetectable from this data. - Multi-class share fallback: weighted_avg_diluted_shares is captured and used for market cap when the cover-page count is absent (dimensional, so missing from company facts for META/CMCSA/CHTR/FOXA/NWSA/LEN). Within ~0.6% of the true count on controls; flagged shares_estimated in the API. BRK-B has no weighted-average fact either and stays unavailable. 820 unit tests pass; new tests confirmed to fail against the pre-fix code. Effect is inert until existing rows are reparsed (see reparse path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
466 lines
20 KiB
Python
466 lines
20 KiB
Python
"""Tests for the companyfacts -> snapshot parser, on a realistic Apple-shaped
|
|
fixture (the structure verified by live probe)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import date, datetime, timezone
|
|
|
|
import pytest
|
|
|
|
from app.services.sec_facts_parser import (
|
|
Fact,
|
|
FilingMeta,
|
|
_compose_cash,
|
|
_compose_debt,
|
|
_fiscal_context,
|
|
_select_shares,
|
|
parse_snapshots,
|
|
)
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
def _dur(start, end, val, accn, fy=2026, fp="Q2"):
|
|
return {"start": start, "end": end, "val": val, "fy": fy, "fp": fp, "accn": accn, "form": "10-Q"}
|
|
|
|
|
|
def _inst(end, val, accn, fy=2026, fp="Q2"):
|
|
return {"end": end, "val": val, "fy": fy, "fp": fp, "accn": accn, "form": "10-Q"}
|
|
|
|
|
|
COMPANYFACTS = {
|
|
"cik": 320193,
|
|
"facts": {
|
|
"us-gaap": {
|
|
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
|
|
_dur("2025-09-28", "2025-12-27", 143756, "A", fp="Q1"), # Q1 discrete == YTD
|
|
_dur("2025-09-28", "2026-03-28", 254940, "B"), # Q2 YTD (181d) <- want this
|
|
_dur("2025-12-28", "2026-03-28", 111184, "B"), # Q2 discrete (90d)
|
|
]}},
|
|
"NetIncomeLoss": {"units": {"USD": [_dur("2025-09-28", "2026-03-28", 40000, "B")]}},
|
|
# only a discrete-length fact for Q2 -> must be null, not the discrete
|
|
"OperatingIncomeLoss": {"units": {"USD": [_dur("2025-12-28", "2026-03-28", 30000, "B")]}},
|
|
"EarningsPerShareDiluted": {"units": {"USD/shares": [_dur("2025-09-28", "2026-03-28", 2.55, "B")]}},
|
|
"CashAndCashEquivalentsAtCarryingValue": {"units": {"USD": [_inst("2026-03-28", 30000, "B")]}},
|
|
"MarketableSecuritiesCurrent": {"units": {"USD": [_inst("2026-03-28", 20000, "B")]}},
|
|
"LongTermDebtNoncurrent": {"units": {"USD": [_inst("2026-03-28", 80000, "B")]}},
|
|
"LongTermDebtCurrent": {"units": {"USD": [_inst("2026-03-28", 10000, "B")]}},
|
|
},
|
|
"dei": {
|
|
"EntityCommonStockSharesOutstanding": {"units": {"shares": [
|
|
{"end": "2026-04-17", "val": 14687356000, "fy": 2026, "fp": "Q2", "accn": "B", "form": "10-Q"},
|
|
]}},
|
|
},
|
|
},
|
|
}
|
|
|
|
FILINGS = {
|
|
"A": FilingMeta(date(2025, 12, 27), date(2026, 1, 30), datetime(2026, 1, 30, 11, 1, tzinfo=UTC), "10-Q"),
|
|
"B": FilingMeta(date(2026, 3, 28), date(2026, 5, 1), datetime(2026, 5, 1, 10, 1, tzinfo=UTC), "10-Q"),
|
|
}
|
|
|
|
|
|
def _by_accn(rows):
|
|
return {r.accession: r for r in rows}
|
|
|
|
|
|
def test_parses_ytd_not_discrete_and_cover_date_shares():
|
|
res = parse_snapshots(COMPANYFACTS, FILINGS, {"A", "B"})
|
|
assert not res.skipped_filings and not res.field_issues
|
|
b = _by_accn(res.rows)["B"]
|
|
|
|
assert (b.cik, b.fiscal_year, b.fiscal_period) == ("0000320193", 2026, "Q2")
|
|
assert b.period_end == date(2026, 3, 28)
|
|
assert b.period_start == date(2025, 9, 28) # YTD start (fiscal-year start)
|
|
assert b.revenue == 254940 # the 6-month YTD, NOT the 111184 discrete
|
|
assert b.net_income == 40000
|
|
assert b.operating_income is None # only a discrete-length fact existed -> null
|
|
assert b.diluted_eps == 2.55
|
|
# cash + first-present ST investment (MarketableSecuritiesCurrent), each once
|
|
assert b.cash_and_st_investments == 50000
|
|
# long-term parts summed (no aggregate, no short-term)
|
|
assert b.total_debt == 90000
|
|
assert b.shares_outstanding == 14687356000
|
|
assert b.shares_outstanding_date == date(2026, 4, 17) # cover date != period_end
|
|
assert b.filed_date == date(2026, 5, 1)
|
|
assert b.accepted_at == datetime(2026, 5, 1, 10, 1, tzinfo=UTC)
|
|
|
|
|
|
def test_q1_discrete_is_the_ytd():
|
|
res = parse_snapshots(COMPANYFACTS, FILINGS, {"A"})
|
|
a = _by_accn(res.rows)["A"]
|
|
assert a.fiscal_period == "Q1"
|
|
assert a.revenue == 143756 # Q1 YTD == Q1 discrete
|
|
assert a.period_start == date(2025, 9, 28)
|
|
|
|
|
|
def test_skips_filing_without_usable_period():
|
|
cf = {
|
|
"cik": 320193,
|
|
"facts": {"us-gaap": {"NetIncomeLoss": {"units": {"USD": [
|
|
{"start": "2025-09-28", "end": "2026-03-28", "val": 1, "fy": 2026, "fp": "H1", "accn": "X", "form": "10-Q"},
|
|
]}}}},
|
|
}
|
|
filings = {"X": FilingMeta(date(2026, 3, 28), date(2026, 5, 1), datetime(2026, 5, 1, tzinfo=UTC), "10-Q")}
|
|
res = parse_snapshots(cf, filings, {"X"})
|
|
assert res.rows == []
|
|
assert res.skipped_filings == [{"accession": "X", "reason": "no usable period identity"}]
|
|
|
|
|
|
def test_missing_accession_is_skipped():
|
|
res = parse_snapshots(COMPANYFACTS, FILINGS, {"NOPE"})
|
|
assert res.rows == []
|
|
assert res.skipped_filings == [{"accession": "NOPE", "reason": "no facts or filing metadata"}]
|
|
|
|
|
|
def test_debt_prefers_aggregate_over_parts():
|
|
rd = date(2026, 3, 28)
|
|
facts = [
|
|
Fact("us-gaap", "LongTermDebt", "USD", None, rd, 95000, 2026, "Q2"),
|
|
Fact("us-gaap", "LongTermDebtNoncurrent", "USD", None, rd, 80000, 2026, "Q2"),
|
|
Fact("us-gaap", "LongTermDebtCurrent", "USD", None, rd, 10000, 2026, "Q2"),
|
|
Fact("us-gaap", "CommercialPaper", "USD", None, rd, 5000, 2026, "Q2"),
|
|
]
|
|
# aggregate (95000) used, parts ignored; + one short-term pick (5000)
|
|
assert _compose_debt(facts, rd) == 100000
|
|
|
|
|
|
def test_cash_picks_one_st_investment_source():
|
|
rd = date(2026, 3, 28)
|
|
facts = [
|
|
Fact("us-gaap", "CashAndCashEquivalentsAtCarryingValue", "USD", None, rd, 30000, 2026, "Q2"),
|
|
Fact("us-gaap", "ShortTermInvestments", "USD", None, rd, 15000, 2026, "Q2"),
|
|
Fact("us-gaap", "MarketableSecuritiesCurrent", "USD", None, rd, 20000, 2026, "Q2"),
|
|
]
|
|
# ShortTermInvestments is first in priority -> 30000 + 15000 (not both ST tags)
|
|
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")}
|
|
res = parse_snapshots(cf, filings, {"B"})
|
|
assert res.rows == []
|
|
assert res.skipped_filings == [{"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")}
|
|
res = parse_snapshots(cf, filings, {"B"})
|
|
assert res.rows[0].revenue == 500 # us-gaap Revenues, not the acme concept
|
|
assert res.rows[0].net_income is None # val None ignored
|
|
assert res.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")}
|
|
res = parse_snapshots(cf, filings, {"B"})
|
|
assert len(res.rows) == 1 and res.rows[0].shares_outstanding is None # row kept, shares null
|
|
assert res.field_issues == [{"accession": "B", "reason": "ambiguous shares outstanding"}]
|
|
assert res.skipped_filings == [] # a field issue is NOT a skipped filing
|
|
|
|
|
|
# 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(
|
|
not (os.environ.get("SEC_LIVE") and os.environ.get("SEC_USER_AGENT")),
|
|
reason="set SEC_LIVE=1 + SEC_USER_AGENT to run the live SEC parser check",
|
|
)
|
|
async def test_live_apple_parse_invariants():
|
|
from app.services.sec_client import SecClient
|
|
|
|
def _dt(s):
|
|
return datetime.fromisoformat(s.replace("Z", "+00:00")) if s else None
|
|
|
|
async with SecClient(user_agent=os.environ["SEC_USER_AGENT"]) as c:
|
|
cf = await c.companyfacts(320193)
|
|
sub = await c.submissions(320193, include_history=False)
|
|
|
|
filings = {
|
|
f["accession"]: FilingMeta(
|
|
date.fromisoformat(f["report_date"]),
|
|
date.fromisoformat(f["filing_date"]),
|
|
_dt(f["acceptance_datetime"]),
|
|
f["form"],
|
|
)
|
|
for f in sub["filings"]
|
|
if f["report_date"] and f["filing_date"] and f["acceptance_datetime"]
|
|
}
|
|
rows = parse_snapshots(cf, filings, set(filings)).rows
|
|
assert len(rows) > 20
|
|
assert all(r.period_end and r.fiscal_year and r.fiscal_period for r in rows)
|
|
# YTD revenue is non-decreasing within a fiscal year
|
|
by_fy: dict[int, list] = {}
|
|
for r in rows:
|
|
if r.revenue is not None:
|
|
by_fy.setdefault(r.fiscal_year, []).append((r.fiscal_period, r.revenue))
|
|
order = {"Q1": 1, "Q2": 2, "Q3": 3, "FY": 4}
|
|
for fy, series in by_fy.items():
|
|
series.sort(key=lambda x: order[x[0]])
|
|
vals = [v for _, v in series]
|
|
assert vals == sorted(vals), f"YTD revenue not monotonic in FY{fy}: {series}"
|
|
# shares cover-date differs from period_end
|
|
latest = max(rows, key=lambda r: r.period_end)
|
|
assert latest.shares_outstanding_date != latest.period_end
|
|
|
|
|
|
# -- revenue concept coverage (A5 parity findings) ---------------------------
|
|
|
|
def _one_filing(concepts: dict, *, start: str, end: str, fp: str):
|
|
"""A single 10-Q whose facts are the given {concept: value} at one YTD span."""
|
|
facts = {
|
|
name: {"units": {"USD": [_dur(start, end, val, "X", fp=fp)]}}
|
|
for name, val in concepts.items()
|
|
}
|
|
companyfacts = {"cik": 19617, "facts": {"us-gaap": facts}}
|
|
filings = {
|
|
"X": FilingMeta(
|
|
date.fromisoformat(end), date(2026, 5, 1), datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q"
|
|
)
|
|
}
|
|
return parse_snapshots(companyfacts, filings, {"X"})
|
|
|
|
|
|
def test_revenue_reads_banks_total_revenue_tag():
|
|
# JPM/GS/WFC tag RevenuesNetOfInterestExpense in every 10-Q and never (or
|
|
# only annually) `Revenues` -- previously null, so revenue growth was too.
|
|
res = _one_filing(
|
|
{"RevenuesNetOfInterestExpense": 49836}, start="2026-01-01", end="2026-03-31", fp="Q1"
|
|
)
|
|
assert res.rows[0].revenue == 49836
|
|
|
|
|
|
def test_revenue_reads_including_assessed_tax_variant():
|
|
# ARE/KHC tag only the Including variant.
|
|
res = _one_filing(
|
|
{"RevenueFromContractWithCustomerIncludingAssessedTax": 671},
|
|
start="2026-01-01", end="2026-03-31", fp="Q1",
|
|
)
|
|
assert res.rows[0].revenue == 671
|
|
|
|
|
|
def test_revenue_concept_priority_is_unchanged_by_the_added_tags():
|
|
# The new entries are appended, so any issuer that already resolved keeps
|
|
# the same concept -- only issuers that resolved to nothing gain a value.
|
|
res = _one_filing(
|
|
{
|
|
"RevenueFromContractWithCustomerExcludingAssessedTax": 100,
|
|
"RevenueFromContractWithCustomerIncludingAssessedTax": 110,
|
|
"RevenuesNetOfInterestExpense": 120,
|
|
"Revenues": 130,
|
|
},
|
|
start="2026-01-01", end="2026-03-31", fp="Q1",
|
|
)
|
|
assert res.rows[0].revenue == 100
|
|
|
|
|
|
def test_four_four_five_q3_ytd_span_is_accepted():
|
|
# A 12/12/12/16-week filer's YTD-Q3 is 36 weeks = 251 days (COST 2026 Q3),
|
|
# which missed the old 20-day tolerance around 273 by ~2 and dropped Q3
|
|
# every year -- breaking the quarter chain and nulling TTM and YoY.
|
|
res = _one_filing(
|
|
{"RevenueFromContractWithCustomerExcludingAssessedTax": 207431},
|
|
start="2025-09-01", end="2026-05-10", fp="Q3",
|
|
)
|
|
assert (date(2026, 5, 10) - date(2025, 9, 1)).days == 251
|
|
assert res.rows[0].revenue == 207431
|
|
|
|
|
|
def test_eps_falls_back_to_continuing_operations_variant():
|
|
# REG tags only this variant on every filing; FCX tags it in its 10-K while
|
|
# using EarningsPerShareDiluted in its 10-Qs.
|
|
companyfacts = {
|
|
"cik": 910606,
|
|
"facts": {"us-gaap": {"IncomeLossFromContinuingOperationsPerDilutedShare": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.81, "X", fp="Q1")]}
|
|
}}},
|
|
}
|
|
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
|
|
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
|
|
res = parse_snapshots(companyfacts, filings, {"X"})
|
|
assert res.rows[0].diluted_eps == 1.81
|
|
|
|
|
|
def test_eps_concept_priority_is_unchanged_by_the_added_tag():
|
|
companyfacts = {
|
|
"cik": 831259,
|
|
"facts": {"us-gaap": {
|
|
"EarningsPerShareDiluted": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.61, "X", fp="Q1")]}},
|
|
"IncomeLossFromContinuingOperationsPerDilutedShare": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.75, "X", fp="Q1")]}},
|
|
}},
|
|
}
|
|
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
|
|
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
|
|
res = parse_snapshots(companyfacts, filings, {"X"})
|
|
assert res.rows[0].diluted_eps == 0.61
|
|
|
|
|
|
# -- period identity from the fiscal calendar, not SEC's fy/fp ---------------
|
|
|
|
from app.services.sec_facts_parser import _period_identity # noqa: E402
|
|
|
|
|
|
def _meta(end: str, form: str = "10-Q") -> FilingMeta:
|
|
d = date.fromisoformat(end)
|
|
return FilingMeta(d, d, datetime(d.year, d.month, d.day, tzinfo=UTC), form)
|
|
|
|
|
|
def test_a_10q_is_never_labelled_fy():
|
|
# BXP: a 10-Q for period end 2026-03-31 carried fy/fp saying "2026 FY", which
|
|
# collided with the real annual row and measured a 90-day fact against the
|
|
# 365-day FY expectation.
|
|
fy, fp = _period_identity(_meta("2026-03-31"), "1231")
|
|
assert (fy, fp) == (2026, "Q1")
|
|
|
|
|
|
def test_december_filer_years_do_not_collide():
|
|
# FRT: two 10-Ks, ending 2024-12-31 and 2025-12-31, both labelled "2024 FY".
|
|
assert _period_identity(_meta("2024-12-31", "10-K"), "1231") == (2024, "FY")
|
|
assert _period_identity(_meta("2025-12-31", "10-K"), "1231") == (2025, "FY")
|
|
|
|
|
|
def test_january_year_end_groups_its_quarters():
|
|
# CRM/CRWD/WDAY: the year ending 2026-01-31 and its own quarters must share a
|
|
# fiscal year, and must not collide with the year ending 2025-01-31.
|
|
assert _period_identity(_meta("2026-01-31", "10-K"), "0131") == (2026, "FY")
|
|
assert _period_identity(_meta("2025-01-31", "10-K"), "0131") == (2025, "FY")
|
|
assert _period_identity(_meta("2025-04-30"), "0131") == (2026, "Q1")
|
|
assert _period_identity(_meta("2025-07-31"), "0131") == (2026, "Q2")
|
|
assert _period_identity(_meta("2025-10-31"), "0131") == (2026, "Q3")
|
|
|
|
|
|
def test_mid_year_end_orders_correctly():
|
|
# STX: the year ending 2025-06-27 was labelled "2027 FY" and sorted after
|
|
# quarters that precede it.
|
|
assert _period_identity(_meta("2025-06-27", "10-K"), "0627") == (2025, "FY")
|
|
assert _period_identity(_meta("2025-10-03"), "0627") == (2026, "Q1")
|
|
assert _period_identity(_meta("2026-01-02"), "0627") == (2026, "Q2")
|
|
assert _period_identity(_meta("2026-04-03"), "0627") == (2026, "Q3")
|
|
|
|
|
|
def test_four_four_five_quarters_place_correctly():
|
|
# COST: a 12/12/12/16-week year leaves Q3 112 days from the year end, not 91.
|
|
assert _period_identity(_meta("2025-11-23"), "0830") == (2026, "Q1")
|
|
assert _period_identity(_meta("2026-02-15"), "0830") == (2026, "Q2")
|
|
assert _period_identity(_meta("2026-05-10"), "0830") == (2026, "Q3")
|
|
assert _period_identity(_meta("2026-08-30", "10-K"), "0830") == (2026, "FY")
|
|
|
|
|
|
def test_year_end_crossing_january_still_groups_one_year():
|
|
# DPZ (fiscalYearEnd 0102): the label shifts by one against Domino's own
|
|
# naming, which is fine -- a year and its quarters must simply agree.
|
|
year, _ = _period_identity(_meta("2025-12-28", "10-K"), "0102")
|
|
assert (year, "FY") == _period_identity(_meta("2025-12-28", "10-K"), "0102")
|
|
assert _period_identity(_meta("2025-03-23"), "0102") == (year, "Q1")
|
|
assert _period_identity(_meta("2025-06-15"), "0102") == (year, "Q2")
|
|
assert _period_identity(_meta("2025-09-07"), "0102") == (year, "Q3")
|
|
|
|
|
|
def test_missing_fiscal_calendar_falls_back_to_filing_context():
|
|
assert _period_identity(_meta("2026-03-31"), None) == (None, None)
|
|
# ...and parse_snapshots then uses the fy/fp path, preserving old behaviour.
|
|
res = parse_snapshots(COMPANYFACTS, FILINGS, {"B"})
|
|
assert (res.rows[0].fiscal_year, res.rows[0].fiscal_period) == (2026, "Q2")
|
|
|
|
|
|
|
|
def test_eps_falls_back_to_basic_only_when_no_diluted_variant_exists():
|
|
# PPL's 2026 Q1 tags no diluted EPS at all, only basic -- one missing period
|
|
# broke the quarter chain and nulled TTM.
|
|
companyfacts = {
|
|
"cik": 922224,
|
|
"facts": {"us-gaap": {"EarningsPerShareBasic": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 0.60, "X", fp="Q1")]}
|
|
}}},
|
|
}
|
|
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
|
|
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
|
|
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
|
|
assert res.rows[0].diluted_eps == 0.60
|
|
|
|
|
|
def test_diluted_still_wins_over_basic_when_both_present():
|
|
companyfacts = {
|
|
"cik": 320193,
|
|
"facts": {"us-gaap": {
|
|
"EarningsPerShareDiluted": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.36, "X", fp="Q1")]}},
|
|
"EarningsPerShareBasic": {
|
|
"units": {"USD/shares": [_dur("2026-01-01", "2026-03-31", 1.40, "X", fp="Q1")]}},
|
|
}},
|
|
}
|
|
filings = {"X": FilingMeta(date(2026, 3, 31), date(2026, 5, 1),
|
|
datetime(2026, 5, 1, 10, tzinfo=UTC), "10-Q")}
|
|
res = parse_snapshots(companyfacts, filings, {"X"}, fiscal_year_end="1231")
|
|
assert res.rows[0].diluted_eps == 1.36
|