Bulk download every 10-K for a list of companies
Loop over a list of tickers, find each company's latest Form 10-K and save the main document to disk with the SecFeed API. Scales to an index list of thousands.
A common first step in text research is a folder with one annual report per company. This tutorial builds that folder: for each ticker in a list it finds the latest Form 10-K, picks the main document out of the filing and saves it.
Setup
import os
import time
import requests
BASE = "https://secfeedapi.com"
HEADERS = {"Authorization": f"Bearer {os.environ['SECFEED_KEY']}"}
def get(path, **params):
r = requests.get(f"{BASE}{path}", params=params, headers=HEADERS, timeout=60)
r.raise_for_status()
return r
Step 1: find the latest 10-K for a ticker
/v1/filings takes a Lucene query and returns newest first, so size=1 gives the latest filing:
hits = get("/v1/filings", query='ticker:AAPL AND formType:"10-K"', size=1).json()["filings"]
Step 2: pick the main document
A 10-K filing is a folder of files: the report itself, exhibits (Ex 21 subsidiaries, Ex 23 auditor consent, Ex 31 and 32 certifications), XBRL files and index pages. /v1/files lists them with sizes. In an inline XBRL 10-K the report is by far the largest HTML file, so take the biggest .htm:
files = get("/v1/files", cik=f["cik"], accessionNo=f["accessionNo"]).json()
docs = [x for x in files if x["name"].endswith(".htm") and "index" not in x["name"]]
main = max(docs, key=lambda x: int(x["size"] or 0))
Do not take the first .htm in the list. Files are sorted by name, and exhibits often sort first: for Apple's latest 10-K the first HTML file is a10-kexhibit21109272025.htm (the subsidiaries list, 11 KB), while the report is aapl-20250927.htm (1.5 MB).
Step 3: the full loop
TICKERS = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL"]
os.makedirs("10k", exist_ok=True)
for t in TICKERS:
hits = get("/v1/filings", query=f'ticker:{t} AND formType:"10-K"', size=1).json()["filings"]
if not hits:
print(t, "no 10-K found")
continue
f = hits[0]
files = get("/v1/files", cik=f["cik"], accessionNo=f["accessionNo"]).json()
docs = [x for x in files if x["name"].endswith(".htm") and "index" not in x["name"]]
main = max(docs, key=lambda x: int(x["size"] or 0)) # the 10-K itself is the largest HTML file
html = get("/v1/download", url=main["url"]).content
path = f"10k/{t}_{f['filedAt']}.htm"
open(path, "wb").write(html)
print(f"{t:6} {f['filedAt']} {main['name']:32} {len(html)/1e6:5.1f} MB -> {path}")
time.sleep(0.2)
Output:
AAPL 2025-10-31 aapl-20250927.htm 1.5 MB -> 10k/AAPL_2025-10-31.htm
MSFT 2026-07-29 msft-20260630.htm 8.6 MB -> 10k/MSFT_2026-07-29.htm
NVDA 2026-02-25 nvda-20260125.htm 2.0 MB -> 10k/NVDA_2026-02-25.htm
AMZN 2026-02-06 amzn-20251231.htm 2.0 MB -> 10k/AMZN_2026-02-06.htm
GOOGL 2026-02-05 goog-20251231.htm 2.6 MB -> 10k/GOOGL_2026-02-05.htm
Five annual reports, 16.7 MB, in about three seconds. The file names show each company's fiscal year end: Apple's year ends in late September, Microsoft's on June 30, NVIDIA's in late January.
Scaling to an index list
The loop is the same for 3,000 tickers; only the input changes.
- Read the list from a file. Put one ticker per line in
tickers.txtand useTICKERS = open("tickers.txt").read().split(). Index constituent lists are published by the index providers; SecFeed does not redistribute them. To work from exchange listings instead, see List companies by exchange. - Several years per company. Raise
size(for examplesize=5) and loop overhits. AddAND filedAt:[2021-01-01 TO 2025-12-31]to bound the period. - Count your calls. Each company costs three calls (search, file list, download). Three thousand companies is about 9,000 calls, inside the Developer plan's monthly quota. The free plan's 500 calls covers about 160 companies.
- Resume after a failure. Skip tickers whose file already exists, so a rerun picks up where it stopped:
if any(name.startswith(f"{t}_") for name in os.listdir("10k")):
continue
- Text instead of HTML. If you only need specific sections, the section extractor returns Item 1A or Item 7 as clean text and saves you from parsing HTML.
Amendments and foreign filers
formType:"10-K" matches only original annual reports. Add "10-K/A" to the list to include amendments, which are often only Part III. Foreign private issuers file 20-F and Canadian issuers 40-F instead of a 10-K, so a ticker such as TSM returns no 10-K.