☰
sec-edgar:从SEC EDGAR批量下载上市公司财报的完整实践
2026/10/20 12:25:02 网站建设 项目流程

简介:SEC-EDGAR 是一套面向金融数据从业者与量化分析师的 Python 开源资源包,可以借助简单命令从美国证券交易委员会 EDGAR 数据库批量抓取各大公司的定期报告、文件和表格。过去逐一访问官网、解析页面不仅烦琐,还容易受接口变动影响;这套资源将常见申报类型、公司标识和索引逻辑统一封装,帮助使用者快速获得 10-Q、10-K 等关键申报材料。压缩包共 103 个文件,大小约 1.13 MB,包含 33 个 Python 模块用于核心抓取与解析,28 个 RST 文档作为使用说明和开发指南,另有 HTML 示例页面、XML 配置映射以及 Shell 脚本,方便部署与二次扩展。目前已有 1462 人学习或浏览。借助内置的 CIK 映射数据和季度索引文件,可迅速定位目标公司;示例页面还展示了单公司多份申报结果及查询返回的多种形态,配合代码能深入理解 EDGAR 的文件组织方式和异常处理逻辑。对想搭建财报数据管道或研究 SEC 申报结构的中高级 Python 开发者,这份资源提供了可直接运行的代码和清晰的配置参考。

1. 为什么是EDGAR:全量定期报告的唯一公开入口

做量化投研或财务文本分析时,最卡人的环节往往不是模型,而是拿不到“干净的全量财报文本”。我见过不少团队绕了一圈,最后又绕回SEC EDGAR——它是唯一能合法、免费、结构化地拿到全美上市公司定期报告全文、财务报表和XBRL表单的公开数据源。sec-edgar这套资源解决的正是“从EDGAR批量下载所有公司定期报告、文件和表格”这条链路:它把索引解析、CIK映射、文件筛选、断点续传、限流调度打包成一套可直接运行的Python工具。适合那些手里有明确分析目标、不想从零写请求头管理和归档去重逻辑的从业者。后面所有内容都以这套工具的实际用法为主线,新手能跟着跑通一个季度,熟手可以直接改配置参数对接自己的数据管道。


2. 三条取数通道:全量索引、公司提交记录与Frame窗口

2.1 通道一:按季度全量索引(company.idx / company.json)

EDGAR每隔一段时间会把全市场的归档记录按季度打包成索引文件,分别有company.idx、form.idx和对应的JSON版本。sec-edgar下载时优先走JSON版,因为它不需要解析定宽文本,字段语义也更清晰。

import json import requests def fetch_full_index(year: int, qtr: int) -> list[dict]: # 注意QTR后面的数字不带零,QTR1/QTR2/QTR3/QTR4 url = f"https://www.sec.gov/Archives/edgar/full-index/{year}/QTR{qtr}/company.json" headers = { "User-Agent": "SampleResearch contact@example.com", "Accept-Encoding": "gzip, deflate" } resp = requests.get(url, headers=headers, timeout=30) resp.raise_for_status() # 返回结构里 entries.company 是列表,每个元素有 cik、name、form、filing_date 等 return resp.json()["entries"]["company"]

这段代码直接拉取某年某季度的全市场提交索引。逻辑是构造固定格式URL,带浏览器级别的请求头,用标准JSON解析。参数说明:year是四位数年份;qtr是季度编号1到4,对应EDGAR的QTR1到QTR4;"entries.company"是这份索引里最重要的字段列表。理解这里的关键是:索引里每一条记录代表该公司在这个季度内提交的某类文件,同一个CIK会出现多次,每一条都对应一个accession number。

拿到索引后,sec-edgar会按照form字段过滤出10-K、10-Q、8-K,再去构造文件下载地址。这里有一个坑已经提前处理好了:索引里给出的filename是相对路径,类似edgar/data/0001234567/000123456789-01-000001.txt,必须拼上https://www.sec.gov/Archives/前缀才能访问,sec-edgar内部做了这个拼接。

2.2 通道二:单公司全历史提交记录(Submissions API)

按季度索引适合批量扫全市场,但如果只关注某几家公司,更高效的方式是走data.sec.gov/submissions接口。这个接口按CIK维度组织和记录,返回的是一个公司所有历史归档记录。

curl -s "https://data.sec.gov/submissions/CIK0000320193.json" \ -H "User-Agent: SampleResearch contact@example.com"

这里CIK要按10位数字零填充。sec-edgar里有一个CIK映射表,把ticker或公司名转成CIK后,再动态拼出上面的地址。返回的JSON结构里有几个重点:

  • cik、name:公司基本标识
  • filings.recent.form:按时间倒序的表单类型列表
  • filings.recent.accessionNumber:与表单列表一一对应的accession number
  • filings.recent.primaryDocument:主文档文件名,通常就是10-K或10-Q的实际内容页
  • filings.recent.documents:这次归档包含的所有文件清单,比如完整提交文本、XBRL实例、财务报表附件

这批字段是后续下载逻辑的地基。sec-edgar下载时会把accessionNumber去掉连字符,拼成目录格式edgar/data/{cik}/{accession_no_plain}/,再结合primaryDocument构造主文档URL。处理大量公司时,逐公司调用一次Submissions接口比拉全量索引再过滤更省流量,因为全量索引会把所有表单类型都拉一遍,而Submissions接口可以直接在服务器端按表单类型筛选。

2.3 通道三:Frame窗口的全文检索入口

前两条通道还是偏向“按公司/按季度”批量下载,sec-edgar还支持用全文检索接口拉取特定财报周期内的文件。这类接口的典型参数是frame=US_2024Q1这种格式,意思是把目标限定在某一个时间段内提交的财报文件。

import requests def search_by_frame(frame: str, query: str = "cash") -> dict: url = "https://efts.sec.gov/Locations" params = { "q": query, "frame": frame, "output": "json" } headers = { "User-Agent": "SampleResearch contact@example.com", } resp = requests.get(url, params=params, headers=headers, timeout=30) resp.raise_for_status() return resp.json()

frame的格式需要按实际接口返回调整,常见的是US_YYYYQn,例如US_2024Q1表示2024年第一季度提交的文件。这个接口的优势是可以按文本内容做初筛,比如q=cash就能过滤出正文里出现“cash”的归档,适合做主题线索挖掘;缺点是返回结果较慢,覆盖范围也不如前两条通透,不适合作为全量下载入口。实际项目里,我一般只在已经有一个候选公司列表、想快速验证某类财报是否存在时用这个通道,批量任务仍然走季度索引或Submissions接口。

2.4 三条通道怎么选

通道URL形态粒度单公司全历史全市场批量下载适合场景
季度全量索引Archives/edgar/full-index按季度全市场否是定期批量同步
Submissionsdata.sec.gov/submissions按CIK全历史是可循环重点公司池
Frame全文检索efts.sec.gov/Locations按时间窗+关键词否否主题验证

上面表格给到的对比点,在参数选型时最值得留意的还是“按季度全量索引”和“按CIK全历史”这两者。做研究时,通常先用Submissions接口把个股的归档记录拉一遍,再用季度索引做整个市场的覆盖检查,两者结果能对上,说明数据完备。


3. 把sec-edgar跑起来:配置、命令与输出目录设计

3.1 配置项没那么玄,先看懂这五个参数

拿到sec-edgar资源包后,第一件事不是急着跑,而是先打开config.yml。这套工具几乎所有行为都由配置文件控制,命令行的参数反而很少。下面是一份我实际用过的配置骨架:

output_dir: "./edgar_dump" user_agent: "QuantResearch research@example.com" req_interval: 0.12 retry_times: 3 forms: - "10-K" - "10-Q" - "8-K" start_year: 1996 end_year: 2025 download_secondary_file: false keep_full_text: true max_file_size_mb: 200

output_dir是落盘根目录,sec-edgar会在下面按{cik}/{filing_date}/{accession}继续建子目录。user_agent必须包含联系方式,这是EDGAR侧最低限度的身份识别,不写会被限流或直接403。req_interval控制两次请求之间的间隔秒数,EDGAR对持续请求的限制是每秒10次以内,但实际跑到每秒8次以上时就很容易触发临时封禁,我习惯取0.12到0.2。forms是表单类型白名单,只保留需要的定期报告,否则会把DEF 14A、EX-10等附件也拉下来,磁盘占用直接翻倍。download_secondary_file设为false时只下载主文档和完整提交文本,不下载R.htm之外的附件,这对文本分析场景最省流量。keep_full_text控制是否保留归档目录里的完整提交文本,通常是一个大.txt文件,包含该次归档的全部文件内容,做全量语料训练时很有用。

配置写完后,直接执行:

python sec_edgar.py --config config.yml --quick

--quick表示只处理最近一个季度的索引,用来验证配置和网络连通性。如果这个命令能在两分钟内把某季度所有10-K下载完,再放开全量参数。

3.2 核心下载循环:索引解析到文件落盘

sec-edgar主流程可以简化为三个步骤:解析索引、构造文件URL、限流下载。下面这段代码展示了季度批量下载的核心循环,理解了它,后续改造成增量同步就顺理成章。

import json import time from pathlib import Path import requests def download_quarter(year: int, qtr: int, config: dict): headers = {"User-Agent": config["user_agent"]} base_dir = Path(config["output_dir"]) / f"{year}-Q{qtr}" base_dir.mkdir(parents=True, exist_ok=True) # 1. 拉季度索引 idx_url = f"https://www.sec.gov/Archives/edgar/full-index/{year}/QTR{qtr}/company.json" idx_data = requests.get(idx_url, headers=headers, timeout=60).json() # 2. 按表单类型过滤,收集目标归档 targets = [] for item in idx_data["entries"]["company"]: if item["form"] in config["forms"]: targets.append({ "cik": item["cik"], "form": item["form"], "date": item["filing_date"], "filename": item["filename"], }) # 3. 逐个下载主文档,并严格控制请求间隔 for target in targets: accession_url = "https://www.sec.gov/Archives/" + target["filename"] file_path = base_dir / f"{target['cik']}_{target['form']}_{target['date']}.txt" if file_path.exists(): continue resp = requests.get(accession_url, headers=headers, timeout=60) resp.raise_for_status() file_path.write_text(resp.text, encoding="utf-8") time.sleep(config["req_interval"])

逻辑说明:第一步拉取季度索引;第二步准确过滤出需要的表单;第三步按索引里的相对路径拼出下载地址,限速落盘。参数说明里最容易被忽略的是target["filename"]这个字段,它已经不是纯文件名,而是包含edgar/data/...的相对路径,所以拼接https://www.sec.gov/Archives/后可以直接下载到完整提交文本.txt。file_path.exists()做的是最简单的断点续传,实际工具里还会用SQLite记已处理的accession number,避免同一季度重复跑时重复下载。

3.3 输出目录结构长什么样

跑完一个季度后,推荐把目录设计到下面这个粒度,后面做数据分析时不需要逐文件打开猜测内容:

edgar_dump/ 2024-Q1/ 0000320193_10-K_2024-01-15.txt 0000320193_10-Q_2024-01-20.txt 0001001234_8-K_2024-02-01.txt ...

文件名里带上CIK、表单类型、归档日期,是为了后面做增量同步时能直接按文件名过滤,不需要重新解析路径。我习惯再加一层按年份归档的目录,比如edgar_dump/2024/,但sec-edgar默认按季度分目录,两种方式都行,只要你自己能解释清楚文件名规则。

3.4 单公司模式怎么用

如果只需要下载一两家公司的全历史报告,直接走Submissions接口更快。sec-edgar的命令行留了一个--cik参数:

python sec_edgar.py --config config.yml \ --cik 0000320193,0001001234 \ --start 2020-01-01 --end 2025-12-31

这段命令会把CIK列表里的公司全历史归档拉下来,但只保留指定时间窗口内的文件。它内部会把CIK补零到10位,再逐家请求data.sec.gov/submissions/...解析出filings.recent里的表单类型和accession number,最后同样进入下载循环。建议在这个命令后面加--dry-run参数看一次计划下载的文件列表,确认没有把DEF 14A这类代理声明混进来,再真正执行。


4. 避坑排查:限流、乱码、缺失文档与增量补齐

4.1 请求被限流,报错403,甚至整个IP段被拒

现象:跑完前几百条记录后,突然连续的requests.exceptions.HTTPError: 403 Client Error。这个错误通常从某一段时间点后集中出现,不是偶发。

原因:EDGAR的食物链很简单——它允许自动化访问,但要求请求频率控制在一定阈值内,并且必须有明确身份的User-Agent。如果请求间隔低于0.1秒,或User-Agent里没有真实邮箱格式的联系方式,多半会被拒。

解决:首先把配置中的req_interval调到0.2,再跑一次。如果仍403,检查User-Agent里是否带了contact@xxx.com这类联系方式,没有就补上。另外EDGAR对单个IP的并发请求数也有限制,sec-edgar默认是单线程串行下载,不要为了加速手动开多线程,真需要并行时最多开两个worker,且总请求速率保持在每秒5次以内。

4.2 下载回来的文件是转义符,不是可读的HTML

现象:用文本编辑器打开主文档,看到的不是整齐的HTML或正文,而是一堆’这类数字实体,尤其是缩写、引号、破折号在大量出现。

原因:EDGAR存档文件里有一部分是SGML/HTML实体转义后的文本,早期的10-K尤其常见。直接用write_text(resp.text)保存会把实体原样写入,后续解析正文时就会遇到大量乱码符号。

解决:在保存前用html.unescape()做一次解码。更稳妥的做法是优先下载primaryDocument指向的那个.htm文件,它通常保留了可读的HTML结构,再在解析时用BeautifulSoup的get_text()提取正文。sec-edgar里keep_full_text保留的完整提交文本也建议做一次统一解码后再落盘。

4.3 某些日期的归档目录里主文档缺失

现象:下载命令执行到某家公司的某次归档时,报404,点开链接发现primaryDocument指向的文件确实不存在,但这次归档在索引里是存在的。

原因:SEC EDGAR的文件目录结构和索引结构不是完全同步更新的,偶尔会出现索引已经登记但文件还在迁移中的情况,尤其是一天之内大批量提交的时段。另一种常见情况是这次归档属于“撤回”或“更正”类型,EDGAR会下线文件但保留索引记录。

解决:在下载逻辑里对404做单独处理,不要直接抛异常终止整个循环。正确做法是:记录这个accession number到failed.json,隔一小时重试一次,若仍404,就手动到EDGAR检索页面确认这单归档是否被撤回。sec-edgar的命令行里有个--retry-failed参数,就是用来重放这个失败清单的。

4.4 同一家公司出现多个CIK,下载重复

现象:某公司A在季度索引里出现了两个不同的CIK,且归档记录各有重叠,导致同一份10-K被下载两次。

原因:SEC允许同一家发行人历史上有多个EDGAR标识,尤其是发生过合并、更名或转入新EDGAR体系的公司。简单的按公司名去重会误伤,因为普通名称字符串匹配无法识别“新旧身份”。

解决:sec-edgar内置了一张CIK映射表,它会在索引解析阶段把同一个cik_str下不同名字指向同一份归档,去重键用的是CIK,而不是公司名。如果你需要按公司名聚合,我的做法是在后期分析阶段用filings.recent.cik来承认不同CIK对应不同实体,不做强行合并,避免误删有效记录。

4.5 增量更新时老文件被重复下载

现象:第一次全量跑完后,第二天再跑同一条命令,发现下载时间没缩短,而且很多文件被覆盖重写。

原因:默认的文件存在性检查只判断文件名,但如果目录结构里不包含归档时间,周期重叠时同名文件会被覆盖。且没有记录“这个accession number已经处理过”的状态。

解决:建议把下载链路改为两步走:第一步读索引并更新一个本地SQLite表,记录所有已成功下载的accession number;第二步只下载表中不存在的记录。sec-edgar的断点续传就是这么做的,它的运行日志里能看到类似skip: 0001234567-24-000012的跳过记录,如果出现这种日志,说明增量逻辑已生效。


5. 验证与进阶:从下载到可用的财务解析

5.1 校验下载完整性

跑完批量任务后,强烈建议做一次完整性校验。sec-edgar里没有内置统一的校验命令,但我习惯用一行脚本快速对比索引中的文件数和本地文件数:

python -c " import json, pathlib idx = json.load(open('company_index.json')) files = list(pathlib.Path('edgar_dump').rglob('*.txt')) print('索引记录数:', len(idx['entries']['company'])) print('本地文件数:', len(files)) "

两者数量相差在允许范围内是正常的,因为索引里包含所有表单类型而本地只保留了配置中的白名单。但如果本地文件数明显少于预期,就要结合失败清单定位具体哪一段出问题。这个校验动作应该成为一种习惯,特别是跑了多个季度之后——我遇到过索引里的某些归档整体下架的情况,没有校验根本发现不了。

5.2 把下载好的文件接进财务解析流程

下载完成只是第一步,真正要落地还得把它们转成结构化数据。sec-edgar下载的完整提交文本里包含10-K正文、表格数据和XBRL实例,推荐的处理方式是先按10-K\n这种段落标记切分,再用正则把“Item 7. Management's Discussion and Analysis”这类章节块提取出来,最后落到DataFrame。

import re from pathlib import Path def extract_item7(file_path: Path) -> str: text = file_path.read_text(encoding="utf-8", errors="ignore") pattern = r"Item\s+7\.\s+Management's Discussion and Analysis(.*?)(Item\s+7A|Item\s+8)" match = re.search(pattern, text, re.S | re.I) return match.group(1).strip() if match else ""

这段提取逻辑用在财务文本分析中很常见,它是从完整提交文本里抓取MD&A章节的常规做法。逻辑是定位“Item 7.”位置,取到下一个Item或Item 7A之前的内容。参数说明:errors="ignore"是为了应对那些没有完全解码的乱码字符,re.S让.匹配换行,re.I忽略大小写。你可以把这个脚本扩展成批量处理整个目录,也可以接入自己的词频统计或情感分析模型。

那个让我印象深刻的教训是在做一次大范围历史回测时,有家公司的10-K内容被误判成纯表格,原因是它在Item 7之后紧跟了一个超长XBRL实例,正则的结束边界没跟上。从那以后我每次批量解析MD&A,都会先随机抽三个不同年份的文件人工目检,再信任脚本结果,这个习惯也推荐给你,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询