fix(sec_edgar): serve a quarterly figure filed only year to date, naming its span

- each row takes the quarter where filed, else its 6- or 9-month figure to date, in a column labelled with the span; a filer that tags 10-Q cash flows only year to date no longer stops at each first quarter
This commit is contained in:
Yijia-Xiao
2026-09-29 05:57:18 +00:00
parent 2486470495
commit 1cf16bd0e4
2 changed files with 90 additions and 31 deletions
+42 -4
View File
@@ -205,8 +205,8 @@ def test_older_periods_fall_back_to_the_tag_the_filer_used_then():
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 391_035_000_000, "2024-11-01", start="2023-09-30")]}}}
values, unit = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", (300, 400))
assert values == {"2015-09-26": 233_715_000_000, "2024-09-28": 391_035_000_000}
"Revenues"), "2026-01-01", ((300, 400),))
assert values == {("2015-09-26", 0): 233_715_000_000, ("2024-09-28", 0): 391_035_000_000}
assert unit == "USD"
@@ -216,8 +216,8 @@ def test_a_period_reported_under_two_tags_takes_the_preferred_one_not_both():
"RevenueFromContractWithCustomerExcludingAssessedTax": {"units": {"USD": [
_fact("2024-09-28", 999, "2024-11-01", start="2023-09-30")]}}}
values, _ = sec_edgar._as_of(facts, ("RevenueFromContractWithCustomerExcludingAssessedTax",
"Revenues"), "2026-01-01", (300, 400))
assert values == {"2024-09-28": 999}
"Revenues"), "2026-01-01", ((300, 400),))
assert values == {("2024-09-28", 0): 999}
@pytest.mark.unit
@@ -283,3 +283,41 @@ def test_a_recast_outside_the_annual_report_still_counts_from_its_filing(monkeyp
assert eps("2019-01-01") == "16.97"
assert eps("2020-01-01") == "2.12"
@pytest.mark.unit
def test_a_cash_flow_filed_year_to_date_is_served_with_its_span(monkeypatch):
"""Many filers tag a 10-Q's cash flows only year to date (3, 6, then 9 months):
a quarterly table of discrete quarters alone would stop at each first quarter."""
ytd = [
_fact("2025-12-27", 30_000_000_000, "2026-01-30", form="10-Q", fp="Q1", start="2025-09-28"),
_fact("2026-03-28", 53_000_000_000, "2026-05-01", form="10-Q", fp="Q2", start="2025-09-28"),
_fact("2026-06-27", 81_000_000_000, "2026-08-01", form="10-Q", fp="Q3", start="2025-09-28"),
]
facts = {"facts": {"us-gaap": {"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": ytd}}}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2025-12-27", "2026-03-28 (6 months)", "2026-06-27 (9 months)"]
assert [r for r in out.splitlines() if r.startswith("Operating Cash Flow")] == ["Operating Cash Flow,30000,53000,81000"]
@pytest.mark.unit
def test_a_row_filed_only_year_to_date_keeps_its_figure_beside_a_row_filed_by_quarter(monkeypatch):
facts = {"facts": {"us-gaap": {
"NetCashProvidedByUsedInOperatingActivities": {"units": {"USD": [
_fact("2026-06-30", 120_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-01-01")]}},
"PaymentsToAcquirePropertyPlantAndEquipment": {"units": {"USD": [
_fact("2026-06-30", 10_000_000, "2026-08-01", form="10-Q", fp="Q2", start="2026-04-01")]}},
}}}
monkeypatch.setattr(sec_edgar, "_fetch_json",
lambda url: TICKER_MAP if "company_tickers" in url else facts)
out = sec_edgar.get_cashflow("AAPL", "quarterly", "2026-09-28")
assert _columns(out) == ["2026-06-30", "2026-06-30 (6 months)"]
rows = {r.split(",")[0]: r.split(",")[1:] for r in out.splitlines()[3:]}
assert rows["Operating Cash Flow"] == ["", "120"]
assert rows["Capital Expenditure"] == ["10", ""]
+48 -27
View File
@@ -77,6 +77,11 @@ _STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = {
# end date, so a match on the end date alone can report half a year as a quarter.
_SPANS = {"quarterly": (60, 115), "annual": (300, 400)}
# A 10-Q's cash flows are often filed only year to date. A quarterly table takes
# the quarter where filed, else the span to date, named in the column; it never
# subtracts one filing from another, which would give a figure no filing states.
_YEAR_TO_DATE = ((150, 200, 6), (240, 290, 9))
# A fiscal year is a period an annual report covers. A 10-Q balance has no span
# to reject, and some filers' 10-Qs report twelve-month totals that pass the span
# check, so either would read as a fiscal year. The value is still the latest
@@ -137,43 +142,48 @@ def cik_for(ticker: str) -> str | None:
return None
def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, span: tuple[int, int],
def _span_index(fact: dict, spans: tuple[tuple[int, int], ...]) -> int | None:
"""Which of ``spans`` a duration fact covers (0 for an instant fact), or None."""
if "start" not in fact:
return 0
days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days
return next((i for i, (low, high) in enumerate(spans) if low <= days <= high), None)
def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, spans: tuple[tuple[int, int], ...],
forms: tuple[str, ...] = ()) -> tuple[dict, str]:
"""({period end: value}, unit) for the first tag the filer reports, as known then.
"""({(period end, span index): value}, unit) for the tags the filer reports, as known then.
A period reported more than once takes its latest filing on or before the
date, so an amendment counts from the day it was filed and not before. The
unit comes from the filing: most lines are USD, earnings per share are
USD/shares, and scaling those alike would print a real figure as zero.
USD/shares, and scaling those alike would print a real figure as zero. A
duration fact (revenue, cash flow) must cover one of ``spans``; an instant
fact (a balance) has no span and serves any.
"""
low, high = span
values: dict[str, float] = {}
values: dict[tuple[str, int], float] = {}
chosen_unit = "USD"
# Tags are tried in order and a period keeps the first one that reports it:
# filers renamed lines over the years, so one tag covers only part of the
# history. Values are never added across tags, which would double count.
for tag in tags:
for unit, unit_values in ((facts.get(tag) or {}).get("units", {})).items():
latest: dict[str, dict] = {}
covered: set[str] = set() # period ends a filing of ``forms`` reports
latest: dict[tuple[str, int], dict] = {}
covered: set[tuple[str, int]] = set() # periods a filing of ``forms`` reports
for fact in unit_values:
if fact["filed"] > as_of_date or fact["end"] in values:
index = _span_index(fact, spans)
key = (fact["end"], index)
if fact["filed"] > as_of_date or index is None or key in values:
continue
# A duration fact (revenue, cash flow) must cover the span asked
# for. An instant fact (a balance) has no span and serves both.
if "start" in fact:
days = (date.fromisoformat(fact["end"]) - date.fromisoformat(fact["start"])).days
if not low <= days <= high:
continue
if not forms or fact.get("form", "").startswith(forms):
covered.add(fact["end"])
seen = latest.get(fact["end"])
covered.add(key)
seen = latest.get(key)
if seen is None or fact["filed"] >= seen["filed"]:
latest[fact["end"]] = fact
latest = {end: fact for end, fact in latest.items() if end in covered}
latest[key] = fact
latest = {key: fact for key, fact in latest.items() if key in covered}
if latest:
chosen_unit = unit
values.update({end: fact["val"] for end, fact in latest.items()})
values.update({key: fact["val"] for key, fact in latest.items()})
return dict(sorted(values.items())), chosen_unit
@@ -189,10 +199,21 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -
raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts")
quarterly = freq.lower() == "quarterly"
span = _SPANS["quarterly" if quarterly else "annual"]
if quarterly:
spans = (_SPANS["quarterly"], *((low, high) for low, high, _ in _YEAR_TO_DATE))
names = ["", *(f" ({months} months)" for _, _, months in _YEAR_TO_DATE)]
else:
spans, names = (_SPANS["annual"],), [""]
forms = () if quarterly else _ANNUAL_FORMS
lines = {label: _as_of(us_gaap, tags, as_of_date, span, forms) for label, tags in _STATEMENTS[kind]}
periods = sorted({end for values, _ in lines.values() for end in values})
lines = {label: _as_of(us_gaap, tags, as_of_date, spans, forms) for label, tags in _STATEMENTS[kind]}
# Each row takes the shortest span it reports for a period, and a column
# holds one span of one period, so a row filed only to date keeps its figure
# beside a row filed by quarter.
chosen = {label: {} for label in lines}
for label, (values, _) in lines.items():
for end, index in values:
chosen[label][end] = min(index, chosen[label].get(end, index))
periods = sorted({(end, index) for spans_of in chosen.values() for end, index in spans_of.items()})
if not periods:
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
@@ -200,7 +221,7 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -
f"# {title} for {ticker.upper()} ({freq}), USD in millions unless the row says otherwise\n"
f"# SEC EDGAR facts filed on or before {as_of_date}, at the values filed then\n\n"
)
rows = [",".join([""] + periods)]
rows = [",".join([""] + [end + names[index] for end, index in periods])]
for label, (values, unit) in lines.items():
# Every row spans the same columns, or a reader lines the table up wrong.
if not values:
@@ -208,10 +229,10 @@ def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -
continue
name = label if unit == "USD" else f"{label} ({unit})"
# Plain numbers: a thousands separator would split the CSV field.
cells = [
(f"{values[p] / 1e6:.0f}" if unit == "USD" else f"{values[p]:.2f}")
if p in values else "" for p in periods
]
cells = []
for end, index in periods:
value = values.get((end, index)) if chosen[label].get(end) == index else None
cells.append("" if value is None else f"{value / 1e6:.0f}" if unit == "USD" else f"{value:.2f}")
rows.append(",".join([name] + cells))
return header + "\n".join(rows) + "\n"