mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-09-27 15:02:39 +03:00
- 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
244 lines
11 KiB
Python
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")
|