Docs
Dashboard
Examples / Browser + shellBeta

Earnings from EDGAR

Yearly revenue, net income and diluted EPS of a few public companies, straight from the filings: SEC EDGAR's official companyfacts API, read in a session's shell with Python. Growth and margins are worked out there too, with a CSV, a chart and a short summary. No model reads or writes a number, so every figure is one a company filed.

When to use it: You want a few public companies' yearly revenue, net income and EPS from their filings, without a model touching a number.

Usesa browser: false shell session, session.files, session.exec (edgar.py: requests, matplotlib)
Needsa plan with shell sessions; your name and email for SEC (SEC_USER_AGENT)
Site[SEC EDGAR APIs](https://www.sec.gov/search-filings/edgar-application-programming-interfaces) (public data; [fair access](https://www.sec.gov/os/accessing-edgar-data): a declared user agent, at most 10 requests a second)
Outputoutput/summary.md, output/earnings.csv, output/chart.png (revenue and net income per year) and output/result.json
LanguagesNode and Python

How edgar.py picks a year's value, which matters more than it looks:

  • only 10-K facts whose period is about a year (350 to 380 days); quarterly 10-Q facts sit in the same lists;
  • each 10-K repeats the two years before it, and a later one may restate a year: for each period end, the value filed last wins;
  • companies change the concept they report revenue under (Revenues, RevenueFromContractWithCustomerExcludingAssessedTax, SalesRevenueNet…): the one with the most recent year is used, and named in the output.

The code

The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.

/** * Earnings from EDGAR: yearly revenue, net income and diluted EPS of a few companies from SEC EDGAR's official * companyfacts API, with growth and margins worked out in a session's shell (Python), a CSV, a chart and a summary. * The numbers are the ones the companies filed; no model reads or writes them. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; TICKERS; SEC_USER_AGENT; a plan with shell sessions) * * Writes output/earnings.csv, output/chart.png, output/summary.md and output/result.json. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const here = dirname(fileURLToPath(import.meta.url));const out = process.env.OUTPUT_DIR ?? "output";const userAgent = process.env.SEC_USER_AGENT;if (!userAgent?.includes("@")) {  console.error('Set SEC_USER_AGENT to your name and email, e.g. "Jane Doe jane@example.com": SEC asks every automated client to declare one.');  process.exit(1);}const env: Record<string, string> = { TICKERS: process.env.TICKERS ?? "AAPL,MSFT,NVDA", SEC_USER_AGENT: userAgent, YEARS: process.env.YEARS ?? "4" };for (const name of ["EDGAR_DATA_URL", "EDGAR_TICKERS_URL"]) if (process.env[name]) env[name] = process.env[name]!;const bx = new Boxline();const session = await bx.sessions.create({ browser: false, shell: true, timeout: 300, userMetadata: { example: "earnings-from-edgar" } });console.log(`Session: ${session.id}`);try {  await session.files.write("edgar.py", readFileSync(join(here, "../edgar.py")));  const r = await session.exec("python3 edgar.py", { env, timeoutMs: 180_000 });  if (r.exitCode !== 0) throw new Error(`edgar.py failed: ${r.stderr.trim().split("\n").at(-1)}`);  const result = JSON.parse(r.stdout);  mkdirSync(out, { recursive: true });  for (const name of ["earnings.csv", "chart.png", "summary.md"]) writeFileSync(join(out, name), await session.files.read(name));  for (const c of result.companies) {    const last = c.years.at(-1);    const growth = last.revenueGrowthPct === undefined ? "" : ` (${last.revenueGrowthPct > 0 ? "+" : ""}${last.revenueGrowthPct}%)`;    console.log(`${c.ticker}, year to ${last.end}: revenue $${(last.revenue / 1e6).toFixed(1)}M${growth}, net margin ${last.netMarginPct}%, EPS ${last.eps}  [${c.revenueConcept}]`);  }  console.log(`${result.requests} requests to EDGAR; output/summary.md, output/earnings.csv, output/chart.png`);  writeFileSync(join(out, "result.json"), JSON.stringify({ tickers: env.TICKERS.split(","), ...result }, null, 2));} finally {  await session.stop();}
edgar.py
"""Runs in the session's shell: yearly revenue, net income and diluted EPS of a few companies from SEC EDGAR'scompanyfacts API (the numbers the companies filed in XBRL), growth and margins worked out here, a CSV, a chart and ashort summary. Prints the result as JSON.Environment: TICKERS (e.g. AAPL,MSFT), SEC_USER_AGENT (your name and email: SEC asks every automated client to declareone), YEARS (default 4), EDGAR_DATA_URL and EDGAR_TICKERS_URL (SEC's by default).How a yearly value is chosen: facts from 10-K filings whose period is about a year (350-380 days), one per period end;when several filings report the same period (each 10-K repeats the two years before it, and may restate one), the onefiled last wins. Quarterly (10-Q) facts are left out."""import csvimport jsonimport osimport timefrom datetime import dateimport matplotlibmatplotlib.use("Agg")import matplotlib.pyplot as plt  # noqa: E402import requests  # noqa: E402TICKERS = [t.strip().upper() for t in os.environ.get("TICKERS", "AAPL,MSFT").split(",") if t.strip()]YEARS = int(os.environ.get("YEARS", "4"))DATA = os.environ.get("EDGAR_DATA_URL", "https://data.sec.gov").rstrip("/")TICKERS_URL = os.environ.get("EDGAR_TICKERS_URL", "https://www.sec.gov/files/company_tickers.json")http = requests.Session()http.headers.update({"User-Agent": os.environ["SEC_USER_AGENT"], "Accept-Encoding": "gzip, deflate"})REVENUE = ["Revenues", "RevenueFromContractWithCustomerExcludingAssessedTax", "SalesRevenueNet", "RevenueFromContractWithCustomerIncludingAssessedTax"]calls = 0def get(url):    global calls    calls += 1    time.sleep(0.15)  # SEC's fair-access limit is 10 requests a second; this stays well under it    r = http.get(url, timeout=60)    r.raise_for_status()    return r.json()def yearly(facts, concept, unit):    """{period end: (value, filed)} for one concept: full-year 10-K values, the latest filing per period."""    out = {}    for f in facts.get(concept, {}).get("units", {}).get(unit, []):        if not f.get("form", "").startswith("10-K") or "start" not in f:            continue        days = (date.fromisoformat(f["end"]) - date.fromisoformat(f["start"])).days        if not 350 <= days <= 380:            continue        if f["end"] not in out or f["filed"] > out[f["end"]][1]:            out[f["end"]] = (f["val"], f["filed"])    return outciks = {v["ticker"].upper(): (v["cik_str"], v["title"]) for v in get(TICKERS_URL).values()}companies = []for ticker in TICKERS:    if ticker not in ciks:        raise SystemExit(f"{ticker} is not in SEC's ticker list")    cik, title = ciks[ticker]    gaap = get(f"{DATA}/api/xbrl/companyfacts/CIK{cik:010d}.json")["facts"].get("us-gaap", {})    # Companies change the concept they file revenue under: take the one with the most recent year.    options = [(c, yearly(gaap, c, "USD")) for c in REVENUE]    concept, revenue = max(options, key=lambda o: (max(o[1], default=""), len(o[1])))    income = yearly(gaap, "NetIncomeLoss", "USD")    eps = yearly(gaap, "EarningsPerShareDiluted", "USD/shares")    years = []    for end in sorted(revenue)[-YEARS:]:        rev = revenue[end][0]        ni = income.get(end, (None,))[0]        years.append({"end": end, "revenue": rev, "netIncome": ni, "eps": eps.get(end, (None,))[0], "filed": revenue[end][1],                      "netMarginPct": round(100 * ni / rev, 1) if ni is not None and rev else None})    for prev, cur in zip(years, years[1:]):        cur["revenueGrowthPct"] = round(100 * (cur["revenue"] / prev["revenue"] - 1), 1)    companies.append({"ticker": ticker, "cik": cik, "name": title, "revenueConcept": concept, "years": years})with open("earnings.csv", "w", newline="") as f:    w = csv.writer(f)    w.writerow(["ticker", "year_end", "revenue_usd", "net_income_usd", "eps_diluted", "revenue_growth_pct", "net_margin_pct", "filed"])    for c in companies:        for y in c["years"]:            w.writerow([c["ticker"], y["end"], y["revenue"], y["netIncome"], y["eps"], y.get("revenueGrowthPct", ""), y["netMarginPct"], y["filed"]])fig, axes = plt.subplots(1, len(companies), figsize=(5 * len(companies), 4), squeeze=False)for ax, c in zip(axes[0], companies):    labels = [y["end"][:7] for y in c["years"]]    xs = range(len(labels))    ax.bar([x - 0.2 for x in xs], [y["revenue"] / 1e6 for y in c["years"]], 0.4, label="Revenue", color="#1f6feb")    ax.bar([x + 0.2 for x in xs], [(y["netIncome"] or 0) / 1e6 for y in c["years"]], 0.4, label="Net income", color="#2da44e")    ax.axhline(0, color="#888", linewidth=0.8)    ax.set_xticks(list(xs), labels)    ax.set_title(f"{c['ticker']} ({c['name']})", fontsize=10)    ax.set_ylabel("USD millions")    ax.legend(fontsize=8)fig.tight_layout()fig.savefig("chart.png", dpi=120)def money(v):    return "n/a" if v is None else f"${v / 1e9:.2f} billion" if abs(v) >= 1e9 else f"${v / 1e6:.1f} million"lines = ["# Earnings from SEC EDGAR", "", "Yearly figures as filed in 10-K reports (XBRL companyfacts); growth and margins computed here.", ""]for c in companies:    last = c["years"][-1]    growth = last.get("revenueGrowthPct")    lines += [f"## {c['ticker']}: {c['name']}", "",              f"Year ending {last['end']}: revenue {money(last['revenue'])}" + (f" ({growth:+.1f}% on the year before)" if growth is not None else "") +              f", net income {money(last['netIncome'])} ({last['netMarginPct']}% of revenue), diluted EPS {last['eps']}.", "",              "| Year end | Revenue | Net income | EPS | Growth | Margin |", "|---|---|---|---|---|---|"]    lines += [f"| {y['end']} | {money(y['revenue'])} | {money(y['netIncome'])} | {y['eps']} | {y.get('revenueGrowthPct', '')} | {y['netMarginPct']} |" for y in c["years"]]    lines.append("")open("summary.md", "w").write("\n".join(lines))print(json.dumps({"companies": companies, "requests": calls}))

Inputs

Environment variables the example reads:

  • SEC_USER_AGENT (required): who you are, e.g. Jane Doe jane@example.com. SEC refuses automated requests without one; it goes in every request's User-Agent header.
  • TICKERS: comma-separated (default AAPL,MSFT,NVDA).
  • YEARS: fiscal years per company (default 4).

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/earnings-from-edgar. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.

# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=…   # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts

Then check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.

What its check proves

check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.

npx tsx check.ts output exits with 0 only when every ticker has at least three years in order, earnings.csv has a row for each, chart.png is a PNG, and every growth and margin figure agrees with the revenue and income beside it. On the stand-in it also compares every year exactly: a restated year must have its later value, quarters must be left out, and every request must have declared a user agent.

Source

github.com/Boxline-dev/examples/earnings-from-edgar on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.

These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.