mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-13 12:52:46 +02:00
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:
+42
-4
@@ -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
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user