用Python分析SEC 13F机构持仓:从爬虫到调仓地图全攻略
2026/8/31 6:14:10 网站建设 项目流程

近半年聊 AI 产业链的人越来越多,但观点也明显分成两派:一边是模型能力还在迭代,一边是部分资金开始从“无差别买入”转向“结构性调仓”。与其听各种二手观点,不如直接看一手数据——美国 SEC 要求符合条件的大型机构每个季度提交 Form 13F,公开披露自己的部分持仓。这份文件既是金融研究的重要素材,也是 Python 数据分析、爬虫和可视化练手的好案例。

本文不预测行情,而是把 13F 当作一份公开数据源,带你从零完成一条完整链路:SEC EDGAR 数据获取 → XML 解析 → 季度持仓对比 → 调仓地图可视化。学完之后,你可以把同一套代码扩展到任意一家披露 13F 的机构,甚至做成定时任务持续监控。

1. 背景:什么是 13F,为什么值得分析

1.1 13F 文件是什么

13F 全称是 Form 13F,由美国证券交易委员会(SEC)要求机构投资经理定期提交的报告。只要机构的管理资产规模超过 1 亿美元,并且持有 13(f) 证券,就需要在每个季度结束后的 45 天内,向 SEC 提交上一季度末的持仓明细。

这里说的 13(f) 证券,通常覆盖在美国交易所上市或有一定规模以上的股票、期权、可转换债券等资产。表中最重要的几项信息包括:

  • 证券名称(name of issuer)
  • 证券类别(title of class)
  • CUSIP 编码(证券唯一标识)
  • 市值(value,单位是千美元)
  • 持股数量(sshPrnamt)
  • 数量类型(SH 表示股数,PRN 表示本金金额)

换句话说,13F 是一份“季度末快照”。它告诉我们:在每一个季度结束时,头部机构手里拿着哪些证券、每只证券值多少钱、持有多少数量。

1.2 为什么 13F 是观察机构资金的窗口

普通投资者很难看到头部机构的实时成交记录,但 13F 提供了目前最接近“透明化”的定期披露窗口。它的优势非常明显:

  1. 数据标准化,所有机构使用同一套表格和字段。
  2. 强制披露,满足条件的机构必须按时提交。
  3. 跨季度可比,同一家机构的历史文件可以纵向对比。
  4. 覆盖范围广,几乎所有大型机构都会出现在 EDGAR 系统里。

比如市场讨论比较多的 AI 产业链,涉及芯片、云计算、应用软件等多个环节。通过对比不同季度的 13F 数据,可以观察到头部机构是在加仓某只芯片股,还是把仓位挪到了软件方向,这种“用脚投票”的过程非常值得用数据分析去还原。

1.3 13F 数据的边界

13F 虽然公开,但并不是一份“完美持仓清单”。使用之前必须清楚它的限制:

  • 存在滞后性:报告在季度结束 45 天之内提交,你看到的数据往往已经是 45 天前的旧信息。
  • 不是全部仓位:部分证券类型不受 13F 约束,例如美国国债、某些外国资产等。
  • 存在隐藏空间:机构可以向 SEC 申请 confidential treatment,暂时隐藏某些持仓。
  • 存在口径差异:同一家公司如果有多个投资经理,可能拆成多份文件,数据合并时要注意重复计算。
  • 单位容易看错:value 字段单位是“千美元”,不是美元,初学者经常在这里踩坑。

理解这些边界,才能在后续做分析时,不把 13F 数据当成“实时买卖信号”来用。

2. 环境准备与整体流程

2.1 环境依赖

本文以 Python 3 为例,建议使用 Python 3.9 及以上版本。核心依赖只有四个:

  • requests:发送 HTTP 请求,从 SEC 下载数据
  • pandas:解析 XML,做季度数据对比
  • lxml:pandas 读取 XML 时依赖的解析器
  • matplotlib:绘制调仓地图

建议先创建一个独立的虚拟环境:

mkdir 13f-analysis && cd 13f-analysis python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate pip install requests pandas lxml matplotlib

版本不需要刻意锁死,以当前 pip 能安装到的最新稳定版为准。安装完成之后,项目结构建议这样规划:

13f-analysis/ ├── config.py # 请求头与路径配置 ├── fetch_13f.py # 下载 13F 数据 ├── analyze_13f.py # 解析与对比 ├── visualize_13f.py # 可视化 └── data/ # 存放下载的 XML

2.2 整体流程

整个分析过程可以拆成五步:

  1. 根据机构名称或股票代码,在 EDGAR 系统里找到对应的 CIK 编号。
  2. 通过 CIK 查询最近的 13F-HR 提交记录。
  3. 下载 13F 信息表 XML 文件。
  4. 用 pandas 解析 XML,获得结构化持仓表。
  5. 合并两个季度的持仓数据,计算调仓变化并可视化。

每一步都不复杂,但每一步都有容易忽略的细节。下面按顺序展开。

3. 从 SEC EDGAR 获取 13F 数据

3.1 SEC 对请求的要求

SEC EDGAR 系统对爬虫是比较友好的,官方提供了结构化 JSON 接口,但同时也要求请求方遵守公平访问原则。最重要的一点是:请求头里必须带上 User-Agent,并且这个 User-Agent 要能联系到你。

建议的格式是:

公司名 联系邮箱

比如:

User-Agent: MyResearchTeam admin@mycompany.com

如果没有规范的 User-Agent,或者请求频率过高,服务器会直接返回 403。另外,官方建议请求频率控制在每秒 10 次以内,日常分析完全不需要这么快,加一个 sleep 反而更稳妥。

3.2 根据 Ticker 查询 CIK

EDGAR 系统内部使用 CIK(Central Index Key)来标识每一家机构。CIK 是一串数字,需要通过查询接口把机构名称或代码转换成 CIK。

先用一个配置文件保存请求头:

# config.py SEC_HEADERS = { "User-Agent": "YourCompanyName admin@yourcompany.com", "Accept-Encoding": "gzip, deflate", }

再编写一个通过股票代码查询 CIK 的函数。SEC 提供了一个公开的映射文件company_tickers.json,里面是所有上市公司的代码和 CIK 对应关系:

# fetch_13f.py import requests from config import SEC_HEADERS def get_cik_by_ticker(ticker: str) -> str: url = "https://www.sec.gov/files/company_tickers.json" resp = requests.get(url, headers=SEC_HEADERS) resp.raise_for_status() data = resp.json() for item in data.values(): if item["ticker"] == ticker.upper(): return str(item["cik_str"]).zfill(10) raise ValueError(f"未找到 ticker: {ticker}")

这里有两个细节需要注意:

  • ticker统一转成大写再匹配,避免大小写不一致。
  • cik_str本身是数字,需要用zfill(10)补足到 10 位,后面拼接 URL 时才是规范格式。

3.3 查询最近的 13F-HR 提交记录

拿到 CIK 之后,可以访问 SEC 的 Submission 接口,获取该机构所有的历史提交记录:

def get_recent_13f_meta(cik: str) -> dict: url = f"https://data.sec.gov/submissions/CIK{cik}.json" resp = requests.get(url, headers=SEC_HEADERS) resp.raise_for_status() data = resp.json() recent = data["filings"]["recent"] for i, form in enumerate(recent["form"]): if form in ("13F-HR", "13F-HR/A"): accession = recent["accessionNumber"][i].replace("-", "") return { "accession": accession, "primary_doc": recent["primaryDocument"][i], "report_date": recent["reportDate"][i], "filing_date": recent["filingDate"][i], } raise ValueError("未找到 13F 记录")

filings["recent"]是一个字典,里面按时间倒序存放了表名、受理号、报告日期、原始文件等信息。我们只关心13F-HR(定期报告)和13F-HR/A(修正报告)。受理号里的横线要移除,因为拼接下载地址时使用的是纯数字格式。

3.4 下载信息表 XML

13F 提交文件的核心内容是信息表(Information Table),通常是一个 XML 文件,文件名一般是InfoTable.xml。拼接下载地址的规则是:

https://www.sec.gov/Archives/edgar/data/{CIK数字部分}/{受理号}/{主文件}

对应代码如下:

from pathlib import Path def download_infotable(cik: str, meta: dict, save_path: str) -> str: cik_num = int(cik) # 去掉前导 0 base = "https://www.sec.gov/Archives/edgar/data" url = f"{base}/{cik_num}/{meta['accession']}/{meta['primary_doc']}" headers = {**SEC_HEADERS, "Accept": "application/xml"} resp = requests.get(url, headers=headers) resp.raise_for_status() Path(save_path).parent.mkdir(parents=True, exist_ok=True) with open(save_path, "wb") as f: f.write(resp.content) return save_path

这里要注意int(cik)把 CIK 字符串转成数字,因为 EDGAR 的文件目录路径里不希望出现前导 0。保存文件时用二进制模式写入,避免文本编码问题。

有的 13F-HR 主文件可能不是InfoTable.xml,而是一个完整的提交文档.txt。遇到这种情况,可以先把主文件下载下来,再在里面定位<informationTable>部分,或者去该 accession 对应的目录下找InfoTable.xml。本文先假设主文件就是信息表,后面会在常见问题里给排查方案。

4. 解析 13F 信息表

4.1 信息表字段说明

打开 13F 的 XML 信息表,核心内容是大量<infoTable>节点。每个节点代表一只证券,常用字段如下:

字段含义注意事项
nameOfIssuer证券发行方名称字符串
titleOfClass证券类别名称例如 Common Stock
cusipCUSIP 编码证券唯一标识,合并的关键字段
value市值单位是千美元
sshPrnamt持股数量也可能是本金金额
sshPrnamtType数量类型SH 是股数,PRN 是本金
putCall期权标识空值表示股票,PUT/CALL 表示期权
investmentDiscretion投资决定权SOLE / SHARED / DEFINED
votingAuthoritySole独立投票权数量通常与持仓数量一致

4.2 用 pandas 解析 XML

pandas 提供了read_xml方法,可以直接把 XML 节点解析成 DataFrame。关键是要传对命名空间,13F 信息表的命名空间是:

http://www.sec.gov/edgar/document/thirteenf/informationtable

解析函数如下:

# analyze_13f.py import pandas as pd THIRTEEN_F_NS = { "ns": "http://www.sec.gov/edgar/document/thirteenf/informationtable" } def parse_13f(xml_path: str) -> pd.DataFrame: df = pd.read_xml( xml_path, xpath=".//ns:infoTable", namespaces=THIRTEEN_F_NS, ) return df

如果不传命名空间,xpath经常匹配不到任何节点,返回值是空表。这也是初学者最常见的解析失败原因之一。

另外,read_xml依赖lxml,如果之前没有安装,会直接报 ImportError。用本文开头的 pip 命令安装即可解决。

4.3 数据清洗

拿到原始 DataFrame 后,需要做几个基础清洗动作:

  • value转成数值类型,方便后续计算。
  • sshPrnamt转成数值类型。
  • cusip统一转成字符串并去掉空格。
  • 过滤掉明显没有意义的空行。
def clean_13f(df: pd.DataFrame) -> pd.DataFrame: df = df.copy() df["cusip"] = df["cusip"].astype(str).str.strip() df["value"] = pd.to_numeric(df["value"], errors="coerce") df["sshPrnamt"] = pd.to_numeric(df["sshPrnamt"], errors="coerce") df = df.dropna(subset=["cusip", "value"]) return df

清洗之后,字段就是干净、可计算的格式了。

5. 构建两个季度的调仓分析

5.1 季度对比思路

13F 是季度末快照,所以“调仓”本质上是两个快照之间的差值。思路很简单:

  1. 下载最近两个季度的 13F 信息表。
  2. 以 CUSIP 为 key,做一次 outer join 合并。
  3. 当前季度持仓减去上季度持仓,得到变动值。
  4. 判断哪些是新建仓、清仓、继续加仓或减仓。

为什么用 outer join?因为一个季度内会出现“新买入了上季度没有的股票”和“清仓了上季度持有的股票”,inner join 会把这两种情况直接丢掉,只有 outer join 能保留完整变化。

5.2 计算持仓变动

合并和计算函数如下:

def compare_quarters(cur: pd.DataFrame, prev: pd.DataFrame) -> pd.DataFrame: cols = [ "nameOfIssuer", "cusip", "titleOfClass", "value", "sshPrnamt", "sshPrnamtType", "putCall", ] cur = cur[cols].copy() prev = prev[cols].copy() for df in (cur, prev): df["value"] = pd.to_numeric(df["value"], errors="coerce") df["sshPrnamt"] = pd.to_numeric(df["sshPrnamt"], errors="coerce") df["cusip"] = df["cusip"].astype(str).str.strip() merged = cur.merge( prev, on="cusip", how="outer", suffixes=("_now", "_prev"), ) # 统一名称 merged["issuer"] = merged["nameOfIssuer_now"].fillna(merged["nameOfIssuer_prev"]) merged["title"] = merged["titleOfClass_now"].fillna(merged["titleOfClass_prev"]) # 空值补 0,便于计算差值 value_now = merged["value_now"].fillna(0) value_prev = merged["value_prev"].fillna(0) shares_now = merged["sshPrnamt_now"].fillna(0) shares_prev = merged["sshPrnamt_prev"].fillna(0) merged["delta_value"] = value_now - value_prev merged["delta_shares"] = shares_now - shares_prev # 标记持仓状态 merged["position_type"] = "持有/调整" merged.loc[(value_prev == 0) & (value_now > 0), "position_type"] = "新建仓" merged.loc[(value_now == 0) & (value_prev > 0), "position_type"] = "清仓" # 按市值变动绝对值排序 merged["abs_delta_value"] = merged["delta_value"].abs() merged = merged.sort_values("abs_delta_value", ascending=False) return merged.reset_index(drop=True)

这个函数会输出一张完整的调仓明细表。delta_value为正代表市值增加,为负代表市值减少。position_type可以直接告诉我们哪些是新买的、哪些是被清掉的。

5.3 运行与结果说明

假设我们已经把最近两个季度分别保存为cur.xmlprev.xml,运行分析:

cur_df = clean_13f(parse_13f("data/current.xml")) prev_df = clean_13f(parse_13f("data/previous.xml")) result = compare_quarters(cur_df, prev_df) print(result[["issuer", "cusip", "delta_value", "delta_shares", "position_type"]].head(10))

预期输出会是一个表格,第一行是市值变动绝对值最大的证券。需要注意,这个“变动”既包含真实买卖,也包含价格波动引起的市值变化。如果想区分两者,需要把sshPrnamt(数量变化)和value(市值变化)放在一起看:数量增加说明真实加仓,数量不变但市值增加则主要是价格上涨。

6. 可视化调仓地图

6.1 加仓与减仓 Top N 柱状图

调仓分析最直观的图表,是“加仓/减仓 Top N”的横向柱状图:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Arial Unicode MS", "WenQuanYi Zen Hei"] plt.rcParams["axes.unicode_minus"] = False def plot_top_changes(merged: pd.DataFrame, top_n: int = 15): top = merged.head(top_n)[["issuer", "delta_value", "position_type"]].copy() colors = ["#d62728" if v > 0 else "#2ca02c" for v in top["delta_value"]] fig, ax = plt.subplots(figsize=(10, 8)) ax.barh(top["issuer"], top["delta_value"], color=colors) ax.set_xlabel("市值变化(千美元)") ax.set_title(f"13F 调仓 Top {top_n}") ax.invert_yaxis() return fig

红色代表加仓,绿色代表减仓。invert_yaxis()可以让变动最大的显示在最上方。

6.2 四象限散点图:真正的“调仓地图”

标题里提到“调仓地图”,更合适的可视化是散点图。横轴放持股数量变化,纵轴放市值变化,点的大小代表当前市值,颜色代表加减仓方向。这样一张图可以同时表达三层信息:

def plot_adjust_map(merged: pd.DataFrame): fig, ax = plt.subplots(figsize=(12, 8)) x = merged["delta_shares"] / 1e4 # 股数转万股 y = merged["delta_value"] size = (merged["value_now"].fillna(0).abs() ** 0.5) / 20 colors = ["#d62728" if v > 0 else "#2ca02c" for v in merged["delta_value"]] ax.scatter(x, y, s=size, c=colors, alpha=0.55) ax.axhline(0, color="gray", linestyle="--", linewidth=0.8) ax.axvline(0, color="gray", linestyle="--", linewidth=0.8) ax.set_xlabel("持股数量变化(万股)") ax.set_ylabel("市值变化(千美元)") ax.set_title("机构调仓地图(四象限视图)") return fig

四个象限的含义非常清晰:

  • 右上角:数量增加,市值增加,属于明显加仓。
  • 左上角:数量增加但市值下降,通常是价格下跌导致,也可能是加仓幅度没有抵消价格跌幅。
  • 右下角:数量减少但市值上升,一般是减仓后价格大涨。
  • 左下角:数量减少,市值下降,属于明显减仓。

这张图很适合用来快速定位“机构最剧烈变动的仓位”。

6.3 保存图片

生成图表之后,直接保存到本地:

fig1 = plot_top_changes(result, top_n=15) fig1.savefig("output/top_changes.png", dpi=150, bbox_inches="tight") fig2 = plot_adjust_map(result) fig2.savefig("output/adjust_map.png", dpi=150, bbox_inches="tight")

bbox_inches="tight"可以避免横轴标签被截断。

7. 常见问题与排查思路

实操过程中最容易遇到的问题,我整理成了一张排查表:

问题现象常见原因解决思路
请求返回 403User-Agent 不规范或请求太频繁按“机构名 邮箱”格式设置 UA,并控制请求间隔
read_xml 报 ImportError缺少 lxml执行 pip install lxml
解析结果是空表XPath 命名空间写错确认 namespaces 参数为 thirteenf/informationtable
同一个 CUSIP 出现多行多家子机构分别提交结合 nameOfIssuer 或 filer 字段做聚合
value 数值看起来偏小单位是千美元需要时乘以 1000 转成美元
找不到 13F-HR 记录机构通过多个主体提交尝试查询母公司或子公司的 CIK
主文件不是 XMLprimaryDocument 是完整提交文档去 accession 目录下找 InfoTable.xml

7.1 403 问题

403 是最高频的问题。绝大多数情况是 User-Agent 没有包含可联系信息,或者单位时间内请求次数过多。建议把请求间隔设置成 0.1 到 0.2 秒,分析几十家机构也不慢,但不会触发限流。

7.2 空表问题

read_xml返回空 DataFrame,优先检查命名空间。13F 信息表的命名空间是固定的,不要自己去猜。如果不想处理命名空间,也可以先读 XML 字符串,把<informationTable>里的内容提取出来再用read_xml解析。

7.3 数据重复问题

同一只股票在表格里出现多次,可能是同一个机构里有多个投资经理,也可能是期权和正股同时存在。分析前最好确认是否需要按putCall过滤,把普通股票和期权分开统计。

8. 最佳实践与工程建议

写到这里,代码链路已经完整了。但作为一个长期维护的数据分析项目,还有几个工程层面的建议值得注意。

8.1 数据工程层面

  • 原始数据必须缓存:每次运行都重新下载,既浪费流量又容易触发限流。建议把下载的 XML 按“CIK + 报告期”命名,存到本地 data 目录,只有不存在时才重新下载。
  • 解析结果落盘:把清洗后的 DataFrame 保存为 Parquet 或 CSV,后续做多机构对比时就不用重新解析 XML。
  • 写一个重试机制:网络请求偶尔会失败,可以用tenacity库或简单循环做指数退避重试。
  • 保留报告期字段:每条记录都应该带上报告期,否则多个季度数据叠在一起会混乱。

8.2 合规与风险层面

  • 遵守 SEC EDGAR 的服务条款,设置合法 User-Agent,控制请求频率。
  • 13F 数据是滞后数据,只能用于研究和验证思路,不能当作实时买卖信号。
  • 本文所有代码与结论仅用于技术学习和数据分析演示,不构成任何投资建议。真实投资决策需要结合更多信息源。

8.3 分析与扩展层面

  • 可以按行业把 CUSIP 映射到 GICS 行业分类,观察资金在 AI 芯片、云计算、软件应用之间的流向。
  • 可以同时拉取多家机构的 13F,做机构之间的横向对比,找出“多家机构同时加仓”的共识方向。
  • 如果想长期跟踪,可以把这个脚本部署成定时任务,每季度自动更新一次数据,再生成调仓地图。

9. 总结与拓展方向

这篇教程完成了一条从原始数据到可视化图表的完整分析链路:用 SEC EDGAR 接口获取 13F 文件,用 pandas 解析 XML 信息表,用 merge 计算季度持仓变化,最后用 matplotlib 画出调仓地图。整个过程不依赖任何付费数据源,纯 Python 就能实现。

下一步可以继续深入的方向有两个。一是把单机构分析扩展成多机构聚合分析,做机构资金流向的横向对比;二是把 CUSIP 映射成行业标签,这样就能回答“头部机构的钱到底在产业链哪个环节进出”这类更有分析价值的问题。

如果你准备拿真实数据动手试一下,可以先从一家你感兴趣的机构开始,跑通完整流程后再逐步增加机构数量和季度数量。13F 数据本身是公开的,分析代码也是可以复用的,这种“公开数据 + 编程能力”的组合,正是数据分析和量化研究最好的练习场。

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

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

立即咨询