Files
tradingagents/tradingagents/dataflows/vendors/sec_edgar.py
T
Yijia-Xiao 674f1087d1 refactor: name the data layer's date argument as_of_date
- vendor functions and date_window take as_of_date, the date data is served as of; the model-facing tool arguments are unchanged
- build_instrument_context and resolve_instrument_context take trade_date, which is what they receive
2026-09-24 19:38:41 +00:00

244 lines
11 KiB
Python

"""Company statements as they were filed, from SEC EDGAR.
Every other fundamentals vendor serves a period's current value and cuts the
statement at the fiscal period end. That is two claims a run should not make: a
period that has ended is not public until the company files, weeks later, and a
figure that was later restated is not what investors saw at the time.
EDGAR reports every fact with the date it was filed, so a run dated ``as_of_date``
serves exactly what was on file by then, restatements included at the vintage
that was current: Apple's 2008 total assets read 39.6B until the 2010 amendment
restated them to 36.2B.
Access needs no key or account, only a User-Agent identifying the caller, which
SEC requires and refuses requests without. US filers only: anything absent from
EDGAR's ticker map falls through to the next configured vendor.
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import date, datetime
from importlib import metadata
from pathlib import Path
import requests
from tradingagents.dataflows.config import get_config
from tradingagents.dataflows.errors import NoMarketDataError, VendorRateLimitError
logger = logging.getLogger(__name__)
_TICKERS_URL = "https://www.sec.gov/files/company_tickers.json"
_FACTS_URL = "https://data.sec.gov/api/xbrl/companyfacts/CIK{cik}.json"
# A filing history only changes when something new is filed, so one fetch per
# company per day serves every date a run asks about.
_CACHE_TTL_SECONDS = 24 * 60 * 60
# Line items, each with the tags filers use for it, best first. First match wins
# and values are never summed across tags: a company reporting revenue under two
# tags would otherwise be counted twice.
_STATEMENTS: dict[str, list[tuple[str, tuple[str, ...]]]] = {
"balance_sheet": [
("Total Assets", ("Assets",)),
("Current Assets", ("AssetsCurrent",)),
("Cash and Equivalents", ("CashAndCashEquivalentsAtCarryingValue",)),
("Total Liabilities", ("Liabilities",)),
("Current Liabilities", ("LiabilitiesCurrent",)),
("Stockholders Equity", ("StockholdersEquity",
"StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest")),
],
"income_statement": [
("Revenue", ("RevenueFromContractWithCustomerExcludingAssessedTax", "Revenues",
"SalesRevenueNet")),
("Cost of Revenue", ("CostOfRevenue", "CostOfGoodsAndServicesSold")),
("Gross Profit", ("GrossProfit",)),
("Operating Income", ("OperatingIncomeLoss",)),
("Net Income", ("NetIncomeLoss",)),
("Diluted EPS", ("EarningsPerShareDiluted",)),
],
"cashflow": [
("Operating Cash Flow", ("NetCashProvidedByUsedInOperatingActivities",
"NetCashProvidedByUsedInOperatingActivitiesContinuingOperations")),
("Investing Cash Flow", ("NetCashProvidedByUsedInInvestingActivities",)),
("Financing Cash Flow", ("NetCashProvidedByUsedInFinancingActivities",)),
("Capital Expenditure", ("PaymentsToAcquirePropertyPlantAndEquipment",
"PaymentsToAcquireProductiveAssets")),
],
}
# A statement's figures cover a span: a quarter is about 90 days, a year about
# 365. One filing reports both the quarter and the year to date under the same
# 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 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
# filing of any form: a recast after a split or spin-off counts from its filing.
_ANNUAL_FORMS = ("10-K", "20-F", "40-F")
def _user_agent() -> str:
"""Who SEC sees. No account or key exists; callers identify themselves.
www.sec.gov, which serves the ticker map, refuses a User-Agent carrying no
contact address: a client name alone or with a project URL gets 403, one
with an address gets 200. So the default carries a placeholder address and
the package version. Set SEC_EDGAR_USER_AGENT to your own name and address
so SEC can reach you about your traffic rather than the project.
"""
configured = os.getenv("SEC_EDGAR_USER_AGENT", "").strip()
return configured or f"TradingAgents/{_version()} (contact@example.com)"
def _version() -> str:
"""The installed package version, so a release identifies itself correctly."""
try:
return metadata.version("tradingagents")
except metadata.PackageNotFoundError:
return "dev"
def _fetch_json(url: str) -> dict:
"""Read a public EDGAR document, respecting SEC's identification rule."""
try:
response = requests.get(url, headers={"User-Agent": _user_agent()}, timeout=30)
response.raise_for_status()
return response.json()
except requests.RequestException as exc:
status = getattr(getattr(exc, "response", None), "status_code", None)
# Every failure here is "this vendor cannot serve it now", so the router
# moves on instead of seeing a transport exception it has no rule for.
raise VendorRateLimitError(f"SEC EDGAR request failed ({status or type(exc).__name__})") from exc
except ValueError as exc:
raise VendorRateLimitError("SEC EDGAR returned an unreadable response") from exc
def _cached_json(url: str, name: str) -> dict:
path = Path(get_config()["data_cache_dir"]) / "sec_edgar" / name
if path.exists() and time.time() - path.stat().st_mtime < _CACHE_TTL_SECONDS:
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
pass # a truncated file is a miss, not a failure
data = _fetch_json(url)
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(data), encoding="utf-8")
os.replace(temp, path)
return data
def cik_for(ticker: str) -> str | None:
"""The filer's CIK, or None when the ticker is not a US filer."""
table = _cached_json(_TICKERS_URL, "company_tickers.json")
wanted = ticker.strip().upper()
for entry in table.values():
if entry.get("ticker", "").upper() == wanted:
return f"{int(entry['cik_str']):010d}"
return None
def _as_of(facts: dict, tags: tuple[str, ...], as_of_date: str, span: tuple[int, int],
forms: tuple[str, ...] = ()) -> tuple[dict, str]:
"""({period end: value}, unit) for the first tag 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.
"""
low, high = span
values: dict[str, 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
for fact in unit_values:
if fact["filed"] > as_of_date or fact["end"] 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"])
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}
if latest:
chosen_unit = unit
values.update({end: fact["val"] for end, fact in latest.items()})
return dict(sorted(values.items())), chosen_unit
def _statement(kind: str, ticker: str, freq: str, as_of_date: str, title: str) -> str:
as_of_date = as_of_date or datetime.now().strftime("%Y-%m-%d")
cik = cik_for(ticker)
if cik is None:
raise NoMarketDataError(ticker, ticker, "not a US SEC filer")
facts = _cached_json(_FACTS_URL.format(cik=cik), f"CIK{cik}.json")
us_gaap = (facts.get("facts") or {}).get("us-gaap")
if not us_gaap:
raise NoMarketDataError(ticker, ticker, "US filer with no us-gaap facts")
quarterly = freq.lower() == "quarterly"
span = _SPANS["quarterly" if quarterly else "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})
if not periods:
raise NoMarketDataError(ticker, ticker, f"no {freq} {title.lower()} filed by {as_of_date}")
header = (
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)]
for label, (values, unit) in lines.items():
# Every row spans the same columns, or a reader lines the table up wrong.
if not values:
rows.append(",".join([label] + ["unavailable (not tagged by this filer)"] * len(periods)))
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
]
rows.append(",".join([name] + cells))
return header + "\n".join(rows) + "\n"
def get_balance_sheet(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Balance sheet as filed on or before ``as_of_date``."""
return _statement("balance_sheet", ticker, freq, as_of_date, "Balance Sheet")
def get_income_statement(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Income statement as filed on or before ``as_of_date``.
A fourth quarter is never derived: filers report it only inside the annual
figure, and subtracting three separately filed quarters would invent a number
with no filing date behind it.
"""
return _statement("income_statement", ticker, freq, as_of_date, "Income Statement")
def get_cashflow(ticker: str, freq: str = "quarterly", as_of_date: str | None = None) -> str:
"""Cash flow statement as filed on or before ``as_of_date``."""
return _statement("cashflow", ticker, freq, as_of_date, "Cash Flow Statement")