☰
用Python解析SEC 13F文件,追踪AI股票机构资金流向
2026/10/24 19:45:07 网站建设 项目流程

13F 是观察美股机构资金最常用的公开数据切口。当市场开始讨论“AI 躺赢时代结束”时,真正能验证这个判断的,不是某条新闻里的单句话,而是每个季度 SEC 收到的一批 13F 文件。管理规模超过 1 亿美元的美国机构投资经理,需要在季度结束后 45 天内提交 13F-HR,列出季度末的多头持仓明细。这里面包含机构名称、CUSIP、发行人、市值、持股数量等信息,加在一起就构成了一张可以复盘的“机构资金流向地图”。

这篇文章要做的事很具体:把整套 13F 数据处理流程拆成可运行的 Python 工程实践,从 SEC EDGAR 获取机构提交记录,解析 13F 信息表,用 AI 相关股票池做筛选,生成机构季度调仓变化表,最后用热力图和排行榜呈现资金流向。文章不会预测股价,也不会下“顶级机构正在大举买入某只股票”的结论,因为单靠一批公开持仓数据根本不够支撑这种判断。真正有价值的是数据链路本身:只有把获取、清洗、匹配、聚合每一环都做扎实,才能在这种高噪声数据里找到可验证的观察角度。

1. 先理解 13F 和“AI 资金流向”到底在分析什么

1.1 13F 的制度逻辑:谁必须报、报什么、什么时候报

13F 是《1934 年证券交易法》第 13(f) 节规定的季度持仓报告。管理资产规模达到 1 亿美元以上的机构投资经理,必须在美国证券交易委员会(SEC)提交 13F-HR 表格,报告季度末持有的公开交易证券头寸。这里的“机构投资经理”涵盖共同基金、对冲基金、信托公司、养老基金、银行和其他具备投资决策权的机构。

报告的时间窗口是每个季度结束后的 45 天内。这意味着你看到的数据永远有至少 45 天延迟,甚至更久。13F 报告的是某个季度最后一个交易日收盘时的持仓快照,而不是整个季度的完整交易流水。这个差别很重要,后文排查部分还会专门展开。

13F 信息表的核心字段并不复杂,常见结构如下:

字段含义说明
nameOfIssuer发行人名称如 NVIDIA CORP、MICROSOFT CORP
titleOfClass证券类别如 COM、CL A、CL B
cusipCUSIP 编号9 位证券识别码,用于精确匹配
value市值单位是千美元,不是美元
shrsOrPrnAmt/sshPrnamt持股数量或本金金额股数通常为整数
shrsOrPrnAmt/sshPrnamtType数量类型SH 表示股数,PRN 表示本金
putCall看跌或看涨期权标志缺省表示普通多仓
votingAuthority投票权分布Sole、Shared、None 三类

有人会忽略 value 字段的单位。如果你把 593402 当成 59.3 万美元,那离真实结论会偏差很大,因为 13F 里这个值实际代表 5.934 亿美元。这一点在代码解析和结果展示阶段都要保持一致。

1.2 为什么大家都在盯 13F,以及它的盲区

13F 受到关注的直接原因,是它提供了观察大型机构行为的窗口。当大量头部机构在同一季度增持某个板块时,你至少可以看到一种“机构共识”;当持仓出现明显分化时,你也能够看出不同资金方对同一主题的态度差异。这正是“AI 躺赢时代结束”这类讨论最常见的数据依据。

但 13F 有四个明显的盲区,分析前必须理解。

第一,延迟。报告在季度结束后 45 天内提交,等你在 EDGAR 上拿到数据,市场可能已经走了好几个回合。第二,只披露多头头寸,不披露空头头寸。一家机构可能一边通过多头买入某只 AI 芯片股,一边通过空头或衍生品对冲风险,但你在 13F 里看到的只是多头侧。第三,持仓变化不是交易行为。期末市值减少既可能是卖出,也可能是股价下跌,单看价值字段无法区分。第四,门槛和口径。管理规模不足 1 亿美元的机构不需要报告,所以 13F 不能代表全部聪明钱。

这些盲区不是 13F 的缺陷,而是分析时需要遵守的边界。后文的聚合逻辑和结论展示都会把这些边界考虑进去。

1.3 从原始文件到“调仓地图”的处理链路

整个分析流程可以拆成五步:第一步,找到目标机构的 CIK;第二步,从 SEC submissions API 拉取该机构的 13F-HR 提交记录;第三步,下载每个报告期对应的 information table XML 并解析;第四步,用 CUSIP 或股票名称匹配 AI 股票池;第五步,按季度聚合市值、股数和机构数量,生成对比表和热力图。

阶段数据源产出物
机构定位company_tickers.json 或 CIK LookupCIK 列表
提交记录data.sec.gov/submissions API报告期、accession number、文件清单
原始文件EDGAR ArchivesXML 信息表
结构化数据自写解析脚本DataFrame,字段与 13F 一致
分析与可视化pandas / matplotlib季度调仓表、热力图、净流入排行

确定这条链路后,环境准备就变得非常明确。

2. 环境准备和 EDGAR 访问前置条件

2.1 Python 环境与依赖

本文示例使用 Python 3.10 及以上版本。核心依赖包括 requests 处理 HTTP 请求,pandas 做数据聚合,lxml 解析 XML,matplotlib 和 seaborn 生成图表。建议先创建虚拟环境,再安装依赖。

python -m venv .venv source .venv/bin/activate
pip install requests pandas lxml matplotlib seaborn

也可以把依赖维护成 requirements.txt,方便在服务器或 CI 环境复现:

requests==2.31.0 pandas==2.2.0 lxml==5.1.0 matplotlib==3.8.3 seaborn==0.13.2

需要注意,pandas 2.x 对列名和字符串操作的默认行为有调整,如果你的环境里已经有旧版本,建议先升级,避免后面.str.zfill()或merge()出现类型兼容问题。

2.2 EDGAR 访问规范:User-Agent 和请求频率

SEC 官方接口现在强制要求调用方提供 User-Agent,建议格式是“机构名称 联系方式”,例如:

import requests HEADERS = { "User-Agent": "Research Lab contact@example.com", "Accept-Encoding": "gzip, deflate", }

不要直接用默认 requests 的 python-urllib 字符串,否则 EDGAR 很容易返回 403。SEC 官方文档对访问频率有明确建议,通常不要超过每秒 10 次请求。稳妥做法是每次请求后 sleep 0.1 到 1 秒,尤其是批量下载多个季度文件时。

import time def request_with_policy(url, session=None, headers=HEADERS, pause=0.2): s = session or requests.Session() resp = s.get(url, headers=headers, timeout=10) if resp.status_code in (403, 429): time.sleep(2) resp = s.get(url, headers=headers, timeout=10) resp.raise_for_status() time.sleep(pause) return resp

这里额外增加了一个简单重试逻辑。生产环境建议用 retry 库实现指数退避,而不是只重试一次。

2.3 获取机构 CIK 和季度 13F 提交记录

EDGAR 上每个申报主体都有一个 CIK 编号。最直接的查询方式是通过 SEC 的公开 JSON 文件:

import requests def find_cik_by_keyword(keyword): url = "https://www.sec.gov/files/company_tickers.json" resp = requests.get(url, headers=HEADERS, timeout=10) data = resp.json() result = [] for item in data.values(): title = item.get("title", "") if keyword.upper() in title.upper(): result.append({ "cik": str(item["cik_str"]).zfill(10), "ticker": item.get("ticker"), "title": title, }) return result

如果目标机构没有出现在这个文件中,可以在 SEC 官网的 CIK Lookup 页面手动搜索,然后把 CIK 填入代码。CIK 在请求 submissions 接口时需要补零到 10 位,例如0001067983。

拿到 CIK 后,可以请求 submissions 接口获取该机构最近的 report 列表:

def get_13f_filings(cik_padded, count=12): url = f"https://data.sec.gov/submissions/CIK{cik_padded}.json" resp = requests.get(url, headers=HEADERS, timeout=10) resp.raise_for_status() data = resp.json() recent = data["filings"]["recent"] rows = [] for form, date, accession, report, primary in zip( recent["form"], recent["filingDate"], recent["accessionNumber"], recent["reportDate"], recent["primaryDocument"], ): if form == "13F-HR": rows.append({ "filing_date": date, "report_date": report, "accession": accession, "primary_document": primary, }) if len(rows) >= count: break return rows

这里过滤form == "13F-HR"。有些机构会提交 13F-HR 的修正文件 13F-HR/A,如果要做增量更新,建议对非修正的正式报告去重,避免同一报告期重复统计。

3. 从 EDGAR 下载并解析 13F 信息表

3.1 定位真正的 information table 文件

很多人第一次下载 13F 时,会直接拿primaryDocument拼接下载链接,结果打开 XML 却找不到数据节点。原因是 13F-HR 的主文档通常是提交说明,真正的证券持仓明细在一个附属文件中,常见名称是InfoTable.xml或InformationTable.xml。

最可靠的方法是通过 index.json 列出该 accession 下所有文件,再筛选名称中包含 InfoTable 的 XML:

def list_info_tables(cik, accession): accession_no_dash = accession.replace("-", "") index_url = ( f"https://www.sec.gov/Archives/edgar/data/{cik}/" f"{accession_no_dash}/index.json" ) resp = request_with_policy(index_url) data = resp.json() files = data["directory"]["item"] candidates = [] for f in files: name = f["name"] if name.lower().endswith(".xml") and "info" in name.lower(): candidates.append(name) return candidates

cik 参数在拼接 URL 时可以不补零,直接用数字字符串即可。index.json 里的文件名大小写不统一,所以判断时统一转小写。

解析信息表时要注意命名空间。SEC 的 XML 可能使用不同的 namespace,直接用固定的 namespace 去 xpath 容易拿不到节点。最稳妥的做法是忽略命名空间,按局部名称查找:

from lxml import etree import pandas as pd def parse_infotable(xml_text): root = etree.fromstring(xml_text.encode("utf-8")) records = [] for node in root.xpath("//*[local-name()='infoTable']"): def first(path): match = node.xpath(f".//*[local-name()='{path}']/text()") return match[0].strip() if match else None records.append({ "issuer": first("nameOfIssuer"), "title_of_class": first("titleOfClass"), "cusip": first("cusip"), "value": first("value"), "shares": first("sshPrnamt"), "prn_type": first("sshPrnamtType"), "put_call": first("putCall"), }) return records

字段解析完成后,最好做一次数据类型转换。value 转 int,shares 转 float,cusip 统一补零到 9 位。如果 cusip 字段为空,可以保留原样,后续按名称匹配兜底。

3.2 合并多个机构和多个季度的持仓数据

单个机构单个季度的信息表只是一个小 DataFrame。要做“调仓地图”,必须把多机构、多季度数据纵向拼接成一张宽表。以下代码演示了如何给每行数据加上机构标识和报告期标识:

def load_13f_for_filing(cik, accession, report_date): files = list_info_tables(cik, accession) records = [] for fname in files: file_url = ( f"https://www.sec.gov/Archives/edgar/data/{cik}/" f"{accession.replace('-', '')}/{fname}" ) resp = request_with_policy(file_url) parsed = parse_infotable(resp.text) for row in parsed: row["cik"] = cik row["report_date"] = report_date row["accession"] = accession records.extend(parsed) return pd.DataFrame(records)

这份 DataFrame 就是后续分析的基础数据层。到这一步,你已经完成了 13F 数据从原始文件到结构化数据的转换。

4. 构建 AI 股票池并生成机构调仓地图

4.1 用 CUSIP 优先匹配,股票名称只做兜底

AI 股票池的定义会直接影响分析结果。下面给出一个示例配置,用于演示匹配逻辑,不构成投资建议。CUSIP 应从行情数据或 EDGAR 实际记录中核对后再用于生产环境。

WATCHLIST = [ {"ticker": "NVDA", "name": "NVIDIA", "cusip": "67066G104"}, {"ticker": "MSFT", "name": "MICROSOFT", "cusip": "594918104"}, {"ticker": "GOOGL", "name": "ALPHABET", "cusip": "02079K305"}, {"ticker": "META", "name": "META", "cusip": "30303M102"}, {"ticker": "AMZN", "name": "AMAZON", "cusip": "023135106"}, {"ticker": "AMD", "name": "ADVANCED MICRO DEVICES", "cusip": "007903107"}, {"ticker": "AVGO", "name": "BROADCOM", "cusip": "11135F101"}, ]

匹配时优先使用 CUSIP,因为它是 9 位唯一证券标识,比名称匹配稳定得多。不同机构对同一公司的名称写法可能有差异,比如“NVIDIA CORP”和“NVIDIA CORPORATION”,但 CUSIP 不会变。

def match_watchlist(df): watch_df = pd.DataFrame(WATCHLIST) df = df.copy() df["cusip9"] = df["cusip"].fillna("").astype(str).str.zfill(9) merged = df.merge( watch_df[["ticker", "cusip"]].rename(columns={"cusip": "watch_cusip"}), left_on="cusip9", right_on="watch_cusip", how="inner", ) return merged

如果某些股票在本地数据源中确实没有 CUSIP,再使用名称模糊匹配。模糊匹配的核心风险是误配,所以建议把匹配结果中的issuer和股票池里的name打印出来人工抽检。

4.2 按季度聚合并计算持仓变化

数据匹配完成后,需要按 report_date 和 ticker 聚合。聚合维度一般包括加总市值、加总股数、持仓机构数量三个指标。

def aggregate_by_quarter(merged_df): grouped = merged_df.groupby(["report_date", "ticker"]).agg( total_value=("value", lambda s: pd.to_numeric(s, errors="coerce").sum()), total_shares=("shares", lambda s: pd.to_numeric(s, errors="coerce").sum()), holder_count=("cik", "nunique"), ).reset_index() return grouped

注意这里直接用市值加总会忽略价格变化对市值的贡献,但作为“资金是否集中流入某个标的”的初步观察是够用的。要更精确地区分“主动买入”和“价格上涨导致市值上升”,需要把期末市值除以股价得到调整后股数,再对比股数变化。

对比两个季度时,可以把季度列做 pivot:

def build_change_table(current, previous): pivot_value = current.pivot_table( index="ticker", columns="report_date", values="total_value", ) diff = pivot_value.diff(axis=1).iloc[:, -1] return diff

更完整的做法是把两个季度的 DataFrame 按 ticker join,然后计算 value_change、shares_change、holder_change。这些字段就是“调仓地图”中最核心的原始信号。

4.3 用热力图和排行榜展示资金流向地图

聚合结果适合用两种图表呈现。第一种是季度市值热力图,横轴是报告期,纵轴是股票,单元格颜色表示加总市值;第二种是最近一个季度净流入排行榜,用条形图展示哪些标的在目标机构集合中获得更多资金关注。

import matplotlib.pyplot as plt import seaborn as sns def plot_heatmap(value_by_quarter): pivot = value_by_quarter.pivot_table( index="ticker", columns="report_date", values="total_value", ) pivot = pivot.fillna(0) plt.figure(figsize=(10, 6)) sns.heatmap(pivot, annot=True, fmt=".0f", cmap="RdYlGn", cbar_kws={"label": "thousand USD"}) plt.title("AI watchlist total reported value by quarter") plt.tight_layout() plt.show()
def plot_value_change_ranking(change_df, top_n=10): df = change_df.sort_values("value_change", ascending=False).head(top_n) plt.figure(figsize=(8, 6)) colors = ["#2a9d8f" if v > 0 else "#e76f51" for v in df["value_change"]] plt.barh(df["ticker"], df["value_change"], color=colors) plt.xlabel("Value change (thousand USD)") plt.title("Top AI tickers by institutional value change") plt.tight_layout() plt.show()

这两张图构成一个直观的“资金流向地图”。但图只是分析的第一步,真正有价值的是你能解释图表背后的口径和不确定性。

5. 常见问题排查:从请求失败到结论失真

5.1 EDGAR 请求返回 403 或 429

问题现象常见原因检查方式处理建议
403 ForbiddenUser-Agent 不符合要求查看请求头是否包含可识别的 UA设置包含联系方式的自定义 UA
429 Too Many Requests请求频率超过限制统计同一 IP 的请求间隔每次请求后 sleep 0.2 秒以上
连接超时网络环境访问 EDGAR 不稳定测试 index.json 连接增加 timeout 和重试退避

5.2 解析结果为空或字段缺失

有时 XML 下载成功,但parse_infotable返回空列表。原因通常是文件名的判断条件过严,或者 XML 根节点结构不是预期布局。先打印 XML 前 200 个字符,确认文件中是否真的包含informationTable节点,再检查local-name()是否匹配。

如果字段缺失,比如shares为空,可能是因为该持仓是债券或优先股,数量类型为 PRN。此时不应该强行把 shares 转 float,而是把prn_type一并保留,分析时只筛选prn_type == "SH"的股票持仓。

5.3 CUSIP 匹配不上或匹配错误

CUSIP 在解析时可能因为前导零被丢弃。例如 CUSIP 是007903107,在 Excel 或某些 JSON 处理环境中可能被当作数字7903107。比较前统一补零到 9 位是最基本的修复。

另一个坑是同一公司在不同基金里使用不同的 CUSIP,比如不同类别股票。匹配失败时要先看issuer名称和股票池名称是否符合,再决定是否扩大为名称匹配。

5.4 把结论推到过度:数据口径导致的失真

错误推断正确口径
某机构市值减少,说明它在卖市值减少可能来自股价下跌,需要用股数变化验证
某机构清仓,说明它看空13F 不报空头,可能只是转入衍生品避险
多家机构持股市值上升,说明板块被抢筹可能是整个行业股价同步上涨,而不是机构主动加仓
机构数量增加,说明资金流入可能是报告门槛内机构数量变化,不一定是主动买入

最稳妥的做法是同时观察股数变化、机构数量变化和市值变化,并明确标注数据报告期。

5.5 用大模型辅助分析时出现“AI 幻觉”

大模型很适合帮你看摘要,但也很容易基于训练记忆编造“某机构重仓买入某某股票”。直接把解析出的 DataFrame 转成 JSON 再丢给模型,提示词中明确要求只使用输入数据:

请根据以下从 SEC EDGAR 13F 解析出的持仓聚合数据,写一段 100 字内的季度调仓观察。 只能引用输入中出现的 ticker、市值和变化值。如果输入中某个指标缺失,直接说缺失,不要推测。 数据: {json_data}

模型输出后,需要做一步程序校验。比如把摘要中出现的所有数字抽取出来,与 DataFrame 中的真实值做比对,防止模型自作主张补数据。这是把 LLM 纳入数据链路时最容易被忽略的一环。

6. 学习环境到生产环境的工程化改造

6.1 学习环境里怎样算跑通

学习环境的目标不是建立完整数据仓库,而是验证最小链路。只要能做到两点就算跑通:第一,能根据 CIK 下载到真实的 13F 信息表 XML;第二,能解析出一张非空 DataFrame,并通过股票池匹配输出一个季度对比表。

在这一阶段,可以放心忽略拆股调整、退市标的、重复报告等边界情况。因为你的目的只是理解数据结构和流程。

6.2 生产环境需要补的工程能力

生产环境的最大要求不是代码更花哨,而是数据可追溯、更新可重复、错误可发现。建议至少补齐四件事。

第一,把原始数据落库。SQLite 就能满足中小规模分析,建表时把报告期、accession、cusip、机构 cik 都记录下来,方便排重和重算。

CREATE TABLE IF NOT EXISTS raw_13f ( id INTEGER PRIMARY KEY AUTOINCREMENT, cik TEXT NOT NULL, report_date TEXT NOT NULL, accession TEXT NOT NULL, cusip TEXT, issuer TEXT, value_thousands INTEGER, shares REAL, prn_type TEXT, put_call TEXT, loaded_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX IF NOT EXISTS uq_13f_row ON raw_13f(cik, accession, cusip, put_call);

第二,维护运行批次记录。每次下载时记下开始时间、报告期、文件数量、解析成功数量,方便排查半包数据。第三,对 SEC 请求做限流和重试,超过 5 次失败后告警,而不是静默跳过。第四,做校验。每个报告期的记录数应在合理范围内,CUSIP 长度必须为 9 位,value 必须大于 0。

6.3 可扩展方向

跑通 13F 单机构分析后,可以从几个方向扩展:

  • 把分析对象从单机构扩展到一个基金组合列表,自动生成多机构合并调仓报告。
  • 用行业分类或 ETF 成分股替代手工维护的股票池,让“AI 相关标的”的界定更透明。
  • 把季度数据接入可视化 BI,定时刷新,形成长期监控看板。
  • 用 LLM Agent 自动生成季度摘要,但保留程序和人工双重校验关卡。

这一步的核心不是追求自动化程度高,而是保证每一步都能回答“数据从哪来、口径是什么、为什么这样算”。

7. 收尾:拿到调仓地图后最该注意什么

13F 数据的最大价值不是告诉你买什么,而是给你一个结构化的观察入口。当你看到“AI 躺赢时代结束”这类说法时,可以用自己的数据链路去验证机构资金在不同 AI 标的是否真的出现分化,而不是被动接受别人的结论。

对新手来说,最有价值的练习是先手动跑通一个机构、两个季度的最小流程,把下载、解析、匹配、聚合并输出图表这些步骤全部走一遍。只有在亲手处理过 XML、CUSIP、合并重复记录和拆股问题之后,你才会真正理解 13F 的边界在哪里。

最后一条技术判断是:不要把单季变化当作趋势,不要把市值变化当作加减仓结论,也不要把大模型生成的摘要当成可信数据源。数据链路越扎实,你的判断越保守,也越接近真实情况。

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

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

立即咨询