资讯动态

sec-edgar:批量下载EDGAR定期报告与财报表格解析

发布时间:2026/10/20 13:19:25 来源:尧图企业网站定制
简介这是一份面向金融数据分析师、量化研究者与 Python 开发者的 SEC EDGAR 批量下载工具包解决逐个公司、逐个表单抓取定期报告的低效问题可通过命令行或函数调用一键拉取 10-Q、10-K 等定期报告、文件和表格支持自定义 CIK 查询适合需要自动化获取美股公司披露信息的进阶用户。压缩包共 103 个文件整体约 1.13MB其中 33 个 py 文件构成核心抓取逻辑28 个 rst 文档覆盖安装、用法与接口说明11 个 html 展示单公司与多公司检索结果样式另有 xml 配置、shell 脚本、索引与 CIK 映射数据目录层次清晰便于阅读源码或二次封装。目前已有 1462 人学习使用。读者能得到项目完整源码、安装配置、CIK 映射表、批量下载示例及常见查询页面样例配合文档可快速理解 EDGAR 的索引与递交规则并复用现成脚手架完成多公司定期报告抓取。1. 不只有爬虫sec-edgar 是批量抓取 EDGAR 定期报告的一体化工具做美股基本面研究、量化建模或者财务数据清洗的人迟早都会撞上同一个需求把某家公司过去几年的 10-K、10-Q 和 8-K 全部拉下来再把里面三张报表的表格数据拆成干净的结构化格式。如果只用 requests 直接打 EDGAR 的公开接口你会发现事情比想象中麻烦很多——EFTS 接口有速率限制、submit 索引字段需要解析、文件类型和格式过滤规则需要自己记、表格解析更是坑坑洼洼。sec-edgar 这套 Python 工具要解决的正是从 EDGAR 数据库批量下载所有上市公司定期报告并且同时处理文件和表格这一整条链路。它不是单纯的爬虫而是把 CIK 管理、提交历史索引、文件下载解析和表格提取整合成一体的 Python 工具包适合需要快速跑批量的金融数据从业者也适合刚接触 SEC 公开数据的新手拿来当作规范的数据下载框架。2. 抓 EDGAR 前先搞懂四件事CIK、提交索引、报告文件与速率约束2.1 CIK 不是股票代码但它是 EDGAR 里的「身份证」做 EDGAR 数据抓取时最容易犯的错误是把股票代码当作公司标识直接用。EDGAR 内部对每家公司的唯一标识是 CIKCentral Index Key一个最多十位的数字编号。股票代码会因为交易所变更、公司改名而发生变化但 CIK 基本不会变。比如某家公司从纽交所退市后又重新上市代码可能换一个但 CIK 始终是同一串数字。sec-edgar 在处理公司名单时内部统一使用 CIK 去做查找和归并而不是依赖 ticker。这意味着在你调用 sec-edgar 之前第一步通常是把手里的 ticker 列表映射成 CIK 列表。常见的做法是维护一张「ticker → CIK」的本地映射表或者每次启动任务时先从 EDGAR 的 company_tickers.json 接口拉全量映射关系。company_tickers.json 这个文件不大约几十万行建议落盘缓存、定期更新而不是每次跑任务都重新拉。import json import requests # 拉取全量 ticker 到 CIK 的映射 url https://www.sec.gov/files/company_tickers.json resp requests.get(url, headers{User-Agent: Research contactexample.com}) data resp.json() cik_map {} for item in data.values(): cik_str str(item[cik_str]).zfill(10) cik_map[item[ticker]] cik_str # 之后用 ticker 找 CIK 就很简单 cik cik_map.get(AAPL) print(cik)这段代码先把全量映射拉到本地内存然后把 CIK 统一补零到十位。EDGAR 的 URL 里几乎所有地方用的都是十位零填充的 CIK如果你不补零拼接出来的 submission 索引 URL 大概率会 404。这里的 User-Agent 也值得单独说SEC 官方要求请求方在 UA 里带上联系方式否则会被限流甚至封 IP。UA 格式建议是“应用程序名 联系方式”不能留空。2.2 submission 索引文件每次去哪个链接拿报告全在这里面EDGAR 把每家公司的全部提交历史记录放在一个 JSON 索引文件里按 CIK 命名路径大致是 /submissions/CIK 十位数字.json。这个文件通常有几 MB 到几十 MB 不等里面包含这家公司从 1994 年至今的所有提交记录。频率上以季度报告、年度报告、临时公告为主体还有不少代理声明和被拒绝的公文书信。sec-edgar 内部就是先拉这个索引文件再根据你指定的报告类型、起止时间从索引里筛出目标文件。索引文件的结构看起来像一堆扁平数组但注意不要在 JSON 字段名上写死解析逻辑因为 SEC 偶尔会调字段结构。实际项目里我一般会提前把索引内容转存成一份本地 SQLite 表之后所有筛选都在本地完成秒级响应。这样设计的好处是重复跑任务不需要反复请求 SEC 接口本地缓存就是你的数据库。import sqlite3 import json conn sqlite3.connect(edgar_submissions.db) cur conn.cursor() cur.execute( CREATE TABLE IF NOT EXISTS submissions ( cik TEXT, form TEXT, filing_date TEXT, report_date TEXT, accession TEXT, primary_doc TEXT, PRIMARY KEY (cik, accession) ) ) with open(submissions_0000320193.json, r) as f: idx json.load(f) recent idx[recent] for i in range(len(recent[accessionNumber])): cur.execute( INSERT OR IGNORE INTO submissions VALUES (?, ?, ?, ?, ?, ?), ( idx[cik], recent[form][i], recent[filingDate][i], recent[reportDate][i], recent[accessionNumber][i], recent[primaryDocument][i], ), ) conn.commit()这段代码把提交索引落进了 SQLite入库时用到最近提交数组各字段。这里要注意一个细节EDGAR 索引的 JSON 里字段是一组平行数组不是对象列表下标一一对应写循环时务必保持所有字段都按下标同步取。踩坑经验告诉我除了 recent 数组老数据分散在成批的 older 文件里当前这一版索引文件只提供两份 older 文件的链接要不要翻历史老报告得看你的任务时间范围。2.3 报告文件本身的规律主文档与 XBRL 文件的差异当你选中了一笔提交记录之后下一步是下载这份报告里真正的正文。报告的正文不一定只有一个文件。一次典型的 10-K 提交里通常包含主文档通常是 htm 或 html 格式的完整报告、XBRL 实例文件、多个 RRA 文件、以及图表和附件。要提取三张财务报表表格的话最干净的数据应该从 XBRL 实例文件里取因为它是结构化标签而最终拿去给人看的排版报表是在主文档的 html 表格里。sec-edgar 在下载文件这层的处理方式通常是根据文件类型筛选文件比如只保留以 .htm 结尾的主报告文档和 xbrl 相关文件忽略那些无用的 css、txt 附件。如果你只是要文字内容抓主文档就够了如果你还要后续做财务指标的计算和口径统一就必须把 XBRL 实例文件一并拉下来。一个常见误区是以为 XBRL 数据和 HTML 报表永远数值一致实际并非如此。有些公司会在 XBRL 里做四舍五入或对某些科目做重分类对照着看才靠谱。2.4 SEC 限速规范网络请求频率不是玄学是有硬性数字的SEC 对 EDGAR 公开接口的访问有一套明文规定核心约束是请求频率不能超过每秒 10 次且同时建议在 User-Agent 中包含申请方信息。超过这个频率接口返回的 HTTP 状态码会从 200 突然变成 403。很多人遇到 403 第一反应是换 IP但实际上十有八九是请求频率超标UA 不合格只是次要因素。sec-edgar 内部的请求队列就默认做了限速处理这是它比你自己写爬虫省心的地方。实际大规模下载任务里最好再加一层保险下载请求加随机延迟间隔在 0.1 到 0.3 秒之间。虽然总量上不如固定限速快但分布更自然不容易触发阈值。为了不至于把所有失败请求重试到怀疑人生我还会在代码里加失败计数器和退避策略连续失败五次就停一分钟再跑。3. 用 sec-edgar 批量下载定期报告核心命令与参数拆解3.1 命令行方式按公司、按报告类型、按时间段一次跑完sec-edgar 提供的入口是命令行工具最简用法是给一个公司列表文件指定报告类型和时间范围工具就会自动完成「查 CIK → 拉索引 → 筛报告 → 下载文件 → 存盘」的完整流程。公司列表文件通常就是一行一个 ticker 或者一行一个 CIK两种都支持。这种设计对需要批量维护投资池数据的人来说很顺手。sec-edgar download \ --companies companies.txt \ --form-types 10-K 10-Q 8-K \ --start-date 2020-01-01 \ --end-date 2024-12-31 \ --output-dir ./edgar_reports \ --filing-index-cache ./edgar_cache \ --rate-limit 8这条命令的含义是把 companies.txt 里列出的公司从 2020 年初到 2024 年底的 10-K、10-Q、8-K 全部下载下来输出到 edgar_reports 目录。参数 rate-limit 设成每秒 8 次比 SEC 的硬上限留了一点余量比默认值更稳。初次跑的时候这个命令会有较长一段时间像是在「卡住」其实是在逐个抓取每家公司的 submission 索引并做本地缓存。如果公司数量比较多建议先把索引缓存跑热再执行真正的下载避免每次重新解析。3.2 Python API 方式把下载嵌入你的研究流程如果不想依赖命令行sec-edgar 同样提供了 Python API。使用场景通常是这样的你有一个候选股票池每天收盘后需要更新最新提交的文件然后做内容筛选。这种情况下直接调 API 更顺不需要每次都走子进程。from sec_edgar import FilingDownloader, FilingFilter downloader FilingDownloader( user_agentResearchAnalysis contactexample.com, cache_dir./edgar_cache, rate_limit8, ) filters FilingFilter() filters.add_company(0000320193) filters.add_form_type(10-K) filters.add_form_type(10-Q) filters.set_date_range(2023-01-01, 2024-12-31) results downloader.download(filters, output_dir./edgar_reports) for item in results: print(item.company_cik, item.form_type, item.accession, item.local_path)这里最关键的参数是 user_agent不要照抄示例里的联系方式建议填真实的组织或应用名加邮箱。SEC 在遇到明显异常的 UA 时会直接拒绝服务。另一个要点是 download 方法返回的结果对象里带 local_path 字段指向下载保存后的本地文件位置。搞清楚这个字段后面接文件解析模块就方便了不需要再去猜文件落点。3.3 目录结构约定下载完成后你的磁盘长什么样下载器默认按「公司 CIK / 报告类型 / 存档编号」三级目录组织文件。落地之后的目录结构大致是这样edgar_reports/ 0000320193/ 10-K/ 0000320193-24-000123/ 0000320193-24-000123.htm 0000320193-24-000123.xbrl这里有一层目录名是 accession number 的变体EDGAR 官方规则是去掉横线的 accession 字符串再拼上后缀。sec-edgar 会用这个编号作为子目录名保证每次提交的下载内容都放在独立文件夹里不会互相覆盖。如果你处理的是同一天多次提交的 8-K这个设计能避免文件互相冲掉。3.4 下载失败重试与断点续跑机制网络是永远不可靠的尤其跑上千家公司的批量任务时总有几条连接会中途中断。sec-edgar 的做法是记录每个任务的完成状态到本地状态文件重新执行时自动跳过已经下载完成的报告。这也是我把缓存目录和输出目录分开的原因缓存目录是索引和状态的存放地输出目录只放报告原文两者解耦之后清理起来很方便。如果跑的过程里某条下载反复失败最有效的处理手段不是反复重试而是把该公司单独拎出来排查。常见原因无非三个这家公司改名导致 CIK 变更、该段时间提交文件缺失、或者这家公司已经注销终止提交。遇到大批量失败时先从这几条入手查通常能快速定位。4. 表格解析把 10-K 里的三张报表拆干净靠的是格式过滤不是眼睛4.1 HTML 表格解析的底层逻辑下载了一堆 10-K 文件之后真正的重活才开始从动辄上千行的 HTML 文档里把资产负债表、利润表、现金流量表里的数字抓出来对齐成一行行「科目-数值」的干净数据。这一步用肉眼做不现实用通用的 pandas.read_html 又常常抓到一堆导航表格和无关表格。sec-edgar 的表格解析模块做法是先定位文档里包含财务报告的那几个区块再基于表头特征去识别哪张表是资产负债表、哪张是利润表。实际解析时我一般会先做一步预处理把所有表格的文本内容归一化然后把包含「CONSOLIDATED BALANCE SHEETS」或「CONSOLIDATED STATEMENTS OF INCOME」这类标志词的表格拎出来。以资产负债表为例表格的行布局通常高度相似但不是完全一致。有些公司会在表头里加脚注引用标记有些会在科目名称后面跟一个英文字母序号。如果直接把文本拿去严格匹配会匹配不到任何值。粗糙的正则无法覆盖全行业这时需要一定程度的模糊匹配。import re from sec_edgar.parsers import TableExtractor extractor TableExtractor() doc_text open(0000320193-24-000123.htm, r, encodingutf-8).read() tables extractor.extract_tables(doc_text, table_typebalance_sheet) # 输出每个科目:数值对 for row in tables: label re.sub(r\s, , row[label]).strip() value row[value] print(f{label} {value})extract_tables 方法返回的是结构化行对象每个对象包含 label 和 value 两个关键字段。label 是清洗过的科目名value 是数值。这里隐含了一个约束表里所有金额单位需要单独处理。很多公司在表头注明「单位百万美元」但表体数字不带单位直接用会出错。sec-edgar 的表头解析会把单位信息单独提取出来存为元数据方便你在数值入库时统一换算。4.2 表格格式差异格式过滤器是解析的生命线为什么说格式过滤器决定了解析成败因为美股上市公司的财报表格格式并没有统一规范。同样是资产负债表有的公司把流动资产单列出来并加总有的公司只在表头顺序排列不用加总行有的公司使用缩进排版有的公司全靠括号标注负值。如果只按固定行号去压数据大概率在不同公司之间翻车。sec-edgar 的策略是让用户通过一组过滤器参数去约束表格外形。比如指定表格必须包含「ASSETS」这个总表头行包含「Current assets」和「Total assets」这两个关键行并且表格最小行数不能少于多少行。过滤条件同时命中后才判定这是目标表格再交给后续的行解析模块。from sec_edgar.parsers import TableFilter table_filter TableFilter() table_filter.require_header(ASSETS) table_filter.require_row_keywords([Total assets, Total liabilities]) table_filter.min_rows(30) filtered extractor.extract_tables(doc_text, table_typebalance_sheet, table_filtertable_filter)这样做的价值在于大幅降低误判率。一份 10-K 文档里有几十张表如果不加过滤程序会把附注里的明细表也当成三张主表后面的科目对齐就彻底乱了。用过这类工具的同行基本都有过同一种体验程序跑完、报表数字对不上最后发现是抓错表格了。格式过滤就是把这种风险提前挡在门外。4.3 对不齐的数值行用脚注标记清洗来对齐解析里最常见的数据质量问题是行内容带着脚注标记。比如科目名写成「Inventories (a)」如果不去掉这个 (a)日期字段对齐的时候就会把 (a) 也当成数据的一部分。sec-edgar 对科目名做统一清洗去掉尾部括号内的字母/数字标记去掉前导空格和尾部空格多个连续空格合并成一个。做完这步科目名才会变得干净才能作为 join key 和另一年的数据对齐。清洗之后还有一步隐形工作将负数统一成标准形式。美国报表里负数有两种写法一种是 -1000一种是 (1000)。如果不统一后面计算同比变化时会出现符号逻辑错误。sec-edgar 内部会把括号负数和带负号的数值都归一化成同样的负数格式这个细节直接影响后续财务报表分析的准确性。import re def normalize_label(label: str) - str: label re.sub(r\([a-zA-Z0-9]\)$, , label.strip()) label re.sub(r\s, , label) return label.strip() def normalize_value(raw_value: str) - float: raw_value raw_value.replace(,, ).strip() if raw_value.startswith(() and raw_value.endswith()): return -float(raw_value[1:-1]) if raw_value.startswith(-): return -float(raw_value[1:]) return float(raw_value)这两段函数是科目名和数值处理的核心逻辑。normalize_label 把尾部脚注标记剥离掉normalize_value 统一处理逗号、括号和负号。它们是所有表格解析结果能对齐的前提。实际处理时记得先做字符串清洗再做 float 转换否则类似 1,234 这种带千分号的数值直接转 float 会抛异常。5. 下载报告最常见的 5 个坑限流、403、文件残缺、内存吃满与日期错位5.1 突然 403请求频率和 UA 都合规矩为什么还被拒现象代码一开始跑得好好的跑了十几分钟突然之间所有请求都返回 403 Forbidden而且持续很久。原因这是 EDGAR 按 IP 维度做的临时封禁通常不是 UA 或频率这些单点问题而是单位时间内请求数量在整体分布上过于密集触发了服务端的风控。即使平均频率低于每秒 10 次如果出现连续短时间爆发比如 0.5 秒内发出 20 个请求也会被认为行为异常。解决把请求分发得更均匀。我会在 sec-edgar 请求层加一个令牌桶令牌每 0.125 秒补充一个每次请求取一个令牌这样能保证速率恒定为 8 次每秒同时将突发请求数量压到最低。另外给 Downloader 增加一个「失败熔断」开关连续收到 403 时自动暂停任务十分钟再恢复。5.2 文件下载到一半内容却是空的或残缺现象报告文件成功落地打开却发现文件大小为 0 KB或者只有前几行内容后面的字节全丢了。原因通常是下载过程中连接被对端重置但 HTTP 状态码仍然返回 200sec-edgar 如果只检查响应状态码而不检查 Content-Length 是否完全接收就会把这个半成品文件当成成功结果保存下来。解决下载完成后强制做完整性校验。方法是比对文件本地大小和响应头里的 Content-Length 字段不一致就删除重下。如果频繁出现多半是本地网络质量不稳定可以开启断点续传模式用 Range 请求把文件剩余部分拉完。5.3 定期报告文件名相似度高新旧版本互相覆盖现象同一家公司同一年度的 10-K 因为修正案原因提交了两次第二次提交的文件直接覆盖了第一次的旧档导致历史数据丢失。原因不同提交批次的 accession number 不同但如果下载目录只按「公司 报告类型 年份」组织两个不同 accession 的文件就会落到同一个文件路径上后者覆盖前者。解决目录层级必须加上 accession number即使用「CIK / 报告类型 / accession / 文件名」的结构。sec-edgar 默认就是这种结构但如果你为了省事改了下载路径模板这个坑肯定会踩。历史档案场景下修正案文件比原版更有价值不能搞丢。5.4 批量任务跑到一半内存被撑爆现象一次性处理 500 家公司的 10-K 文件程序在解析阶段内存占用飙升到 8GB 以上最终 OOM 被系统杀掉。原因把所有文件的 HTML 内容一次性读取到内存里再做解析而不是边读边释放。单个 10-K 的 HTML 文件动辄几十 MB500 份文件叠加起来就是一个无法承受的量级。解决改成流式解析。每次只读一份文件解析完立即把文档对象置空再做下一份。sec-edgar 的 downloader 和 parser 之间也要做成生成器衔接而不是把下载结果先累积成一个大列表再统一解析。用生成器逐个处理文件的写法如下def process_reports(files): for file_path in files: print(fParsing {file_path}) doc open(file_path, r, encodingutf-8).read() tables extractor.extract_tables(doc, table_typebalance_sheet) for row in tables: yield row del doc # 立即释放内存这里用 yield 做逐行产出处理完一份文件就删除文档对象内存始终维持在一个固定水平。跑几千份文件也不会涨内存。这个习惯在任何涉及大文件批处理的场景都值得保持。5.5 报告日期与提交日期混淆筛选结果比你预期多或少现象指定 2024 年 1 月 1 日至 12 月 31 日这一时间范围结果下载到了 2025 年 1 月的 8-K 文件或者漏掉了 2023 年底提交的 10-K。原因EDGAR 数据里存在两个日期字段filing date提交日期和 report date报告期截止日。很多工具默认按 report date 筛选但用户潜意识里以为是按提交日期。对 10-Q 来说报告期截止日通常在提交日期前半个月以上8-K 尤其容易混乱很多 8-K 报告期截止日只写事件发生日和提交日相差很大。解决先明确 sec-edgar 的筛选基准然后按需设置。做事件追踪用提交日期做财务报表对比用报告期截止日。不确定时先把该公司的提交索引导出来看一眼两个字段的差值分布再决定选哪个。6. 进阶增量更新、并发多任务与报告归档的实际习惯下载工具本身并不复杂真正决定效率的是你如何把任务组织成可重复执行的流程。我现在的习惯是把下载、解析、入库三个环节拆成三个独立的阶段每个阶段都有自己的缓存和日志。这样任何一个环节出错都只需要重新跑那一阶段不用从头再来。增量更新的核心是记录每家公司的「最近一次成功抓取的提交索引位置」。sec-edgar 的索引缓存文件里会存下最后一次成功处理的 index 偏移量下次运行只处理新增记录。这个设计在日更场景下特别有用每天只需要拉少数几家新提交的公司而不是每天全量扫描一遍。如果你自己写这套逻辑用一个 SQLite 表记录每家公司的最后 accession 就行新任务只挑索引里在这个 accession 之后的数据。并发多任务方面我一般会开 4 到 6 个 worker 并行每个 worker 共享同一个限速器保证每个 worker 都绕过自身速率的约束。这样总吞吐量能增加但又不至于触发 EDGAR 的限流。文件归档上我现在按「季度快照」做全量落盘每年分四个季度打包压缩解析出来的表格数据入库后报告原文按季度留档。这份工作本身就是一次完整的数据工程流程而 sec-edgar 恰好把其中最容易出错的文件下载和表格解析这两个环节替我先走通了一遍。如果你也要处理 SEC 定期报告这类批量数据建议第一次跑任务之前先拿三五家公司把整条链路调试通再扩大到全量——从那以后我每次跑批量下载都会强制走一遍「小批量验证 → 检查文件和表格完整性 → 再放量」的流程这个习惯帮我少翻了很多次车希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑