ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

利用Python解析SEC EDGAR 13F数据,绘制机构调仓热力图

2026/8/31 13:12:37 拓冰建站 浏览量
利用Python解析SEC EDGAR 13F数据,绘制机构调仓热力图 过去两年市场对 AI 的关注点大多集中在“谁在讲新故事”“谁已经落地收费”“谁的模型跑分又涨了”上。但真正决定资产配置方向的不是发布会上的 Demo而是手握重金的投资经理在每个季度结束时填的那张 13F 表格。最近很多讨论开始转向一个更现实的命题当 AI 叙事从“躺赢”进入“兑现”阶段顶级机构的钱到底还在不在 AI 里又流向了哪里判断这件事最直接的公开数据就是 13F。不过 13F 文件不是整理好的排行榜而是 SEC EDGAR 上的一堆 XML 和文本。自己手动下载、比对、清洗会花掉大量时间。这篇文章就把整套流程拆开告诉你 13F 能看什么、不能看什么以及如何用 Python 批量下载、解析、计算调仓并画出一张“钱流向哪里”的热力图。目标是让读者拿到一份可复用的工程脚本思路而不是等别人整理好的二手结论。如果读者手头有 Python 基础环境跟着文中思路走基本可以完成“下载 13F - 解析持仓明细 - 两期调仓对比 - 可视化输出”这条链路。文中的代码是通用模板实际运行时要按目标机构的 CIK、报告年份和季度做替换。接下来直接进入主题。1. 13F 调仓地图核心能力速览能力项说明数据来源SEC EDGAR 公开文件每个季度由管理规模超过 1 亿美元的投资经理提交数据格式XML、TXT可通过 EDGAR 页面和 API 获取核心字段机构名称、发行人名称、CUSIP、市值、股数、类别、投票权更新频率季度报告报告季度结束后 45 天内披露主要输出机构持仓明细表、两期调仓对比表、行业/个股可视化“地图”技术栈Python 3.9requests、pandas、lxml、matplotlib自动化程度批量下载可自动化数据清洗和结果复核仍需人工确认适合场景跟踪机构调仓、行业轮动观察、策略研究、尽调信息补充主要局限存在 45 天滞后空头仓位一般不上报部分衍生品按名义金额上报不能直接等同于持股数合规边界所有判断基于公开数据不构成投资建议不包含内幕或非公开信息这张表解决的最重要问题是13F 到底能不能做成“地图”。答案是能但这张地图的时滞和颗粒度取决于报告人的披露习惯。所以使用前必须先理解它的边界。2. 13F 能回答什么问题不能回答什么问题13F 在量化分析和基本面研究里被称为“抄作业”的基础数据源但“抄作业”这个词容易误导人。先看它适合回答什么。适合回答的问题有三类。第一类是“某家机构整体加仓还是减仓了”。把同一家机构两个季度的总市值、总持股数做对比就能判断仓位变化。第二类是“某只股票的机构持有者结构”。比如某只 AI 算力股上一季度有 30 家机构持有这一季度变成 22 家这是机构撤离的早期信号。相反如果 CUSIP 相同新增机构数量上升说明市场关注度在提升。第三类是“板块级别的资金迁移”。把所有持仓按行业或主题聚合例如 GPU、数据中心、AI 应用、软件服务再对比两期占比可以做出行业轮动方向的热力图。不适合回答的问题也很明显。第一13F 的披露滞后。季报在季度结束后最多 45 天才公布很多调仓动作发生在更早。如果机构是在季度尾声买入等报告公布时股价可能已经提前反应。直接按 13F 追买等于用旧地图找新路。第二空头仓位不会出现在 13F 中。对于很多对冲基金13F 只展示多头持仓不展示空头和衍生品的完整组合。只看 13F 会高估多头策略的比例。第三部分持仓按名义金额上报比如期权和部分固定收益品种会以 PRN 类型出现与股票 SH 类型的数量口径不同。如果清洗时不区分 sshPrnamtType计算加仓减仓时会出现严重偏差。第四同一家基金管理人可能有多类客户账户13F 是合并上报无法区分专户、公募、私募和养老账户的不同策略。所以13F 调仓地图的正确用法是“辅助观察”不是“抄作业”。它的价值更多在于方向验证当新闻、卖方报告、资金流向数据都指向某个方向时13F 能提供最底层、最不易被包装的持仓数据来交叉验证。3. 环境准备与数据源做 13F 调仓分析不需要高配 GPU也不需要大数据集群。普通 CPU 机器就够因为每个机构每季度持仓通常只有几百到几千行。真正要花心思的是数据获取和清洗。开发语言推荐 Python 3.9 以上。核心依赖如下# 安装核心依赖 pip install requests pandas lxml matplotlib如果希望直接读取 SEC 的 JSON 接口可以额外安装sec-edgar-api或openbb但这不是必须的。直接写 requests 脚本更容易控制请求频率和错误重试。操作系统方面Windows、macOS、Linux 都可以。唯一需要注意的是网络环境SEC EDGAR 站点对请求头有要求必须带User-Agent否则容易收到 403。数据源有三个层次。第一个层次是 SEC EDGAR 的公开查询页面。输入机构名称或 CIK可以浏览历史 13F 文件列表。适合人工查看。第二个层次是 EDGAR 的 JSON API 接口。通过该接口可以获取某个 CIK 的提交历史拿到所有 13F 文件的位置链接。适合程序批量处理。第三个层次是经过清洗的镜像数据集。有一些学术和商业项目会把 13F 数据整理成 CSV 或 SQLite比如 Kaggle 上的历史数据集。这类数据用起来方便但更新可能不及时也不一定有最新的季度数据。从工程化角度看推荐主用 SEC EDGAR 原始数据因为它是唯一完整的公开来源。下面开始搭建本地工作区。# 创建项目目录结构 mkdir -p 13f_map/data/raw mkdir -p 13f_map/data/processed mkdir -p 13f_map/scripts mkdir -p 13f_map/outputdata/raw放下载的 XML 文件data/processed放清洗后的 CSVscripts放 Python 脚本output放图表。文件分目录管理后续批量处理会省很多事。4. 从 SEC EDGAR 批量下载 13F 文件批量下载 13F 的核心是先找到机构对应的 CIK。CIK 是 SEC 对每个实体分配的唯一编号。比如某个机构的名称可能多次变更但 CIK 不变。下面这段代码实现了一个通用查询流程。import requests import time import re HEADERS { User-Agent: Research/1.0 (contactexample.com) } def search_cik(company_name: str) - str | None: 通过 EDGAR 全文本搜索接口查找 CIK。 该接口路径可能随 SEC 站点结构调整运行前请确认当前地址。 url https://www.sec.gov/cgi-bin/browse-edgar params { action: getcompany, company: company_name, type: 13F, dateb: , owner: include, count: 10, } resp requests.get(url, paramsparams, headersHEADERS, timeout30) if resp.status_code ! 200: print(f请求失败: {resp.status_code}) return None # 从返回 HTML 中提取 CIK 的正则规则 match re.search(rCIK(\d{10}), resp.text) if match: return match.group(1) print(未找到 CIK) return None拿到 CIK 后再通过 EDGAR 的 submissions API 获取历史文件列表。def get_filing_metadata(cik: str) - list[dict]: 获取某个 CIK 的近期 13F 提交元数据。 url fhttps://data.sec.gov/submissions/CIK{cik}.json resp requests.get(url, headersHEADERS, timeout30) resp.raise_for_status() data resp.json() filings [] recent_forms data.get(filings, {}).get(recent, {}) for idx, form in enumerate(recent_forms.get(form, [])): if form 13F-HR: report_date recent_forms[reportDate][idx] accession recent_forms[accessionNumber][idx] primary_doc recent_forms[primaryDocument][idx] filings.append({ report_date: report_date, accession: accession, primary_doc: primary_doc, }) return filings这个接口返回的是 JSON比解析 HTML 稳定。注意到 SEC 对请求频率有要求单线程连续请求时建议间隔 0.1 秒以上避免触发限流。下载实际 XML 文件时拼接 URL 即可。EDGAR 文件路径一般是/Archives/edgar/data/{CIK}/{accession_no_dash}/{primary_doc}。def download_13f_xml(cik: str, meta: dict, save_dir: str) - bool: accession_no_dash meta[accession].replace(-, ) url ( fhttps://www.sec.gov/Archives/edgar/data/{cik}/ f{accession_no_dash}/{meta[primary_doc]} ) resp requests.get(url, headersHEADERS, timeout60) if resp.status_code ! 200: print(f下载失败: {resp.status_code} {url}) return False file_name f{cik}_{meta[report_date]}_{meta[accession]}.xml path f{save_dir}/{file_name} with open(path, wb) as f: f.write(resp.content) return True注意文件名里的accession是完整编号含连字符。保存时保留连字符可以避免重名冲突。5. 用 Python 解析 13F XML 持仓明细下载下来的 XML 文件结构并不复杂。核心内容是informationTable节点下的多行持仓明细。下面是一个简化示例。informationTable infoTable nameOfIssuerAPPLE INC/nameOfIssuer titleOfClassCOM/titleOfClass cusip037833100/cusip value123456/value sshPrnamt1234567/sshPrnamt sshPrnamtTypeSH/sshPrnamtType votingAuthority Sole0/Sole Shared0/Shared None1234567/None /votingAuthority /infoTable /informationTable字段含义value是公允价值单位是千美元。sshPrnamt是证券数量或名义金额。sshPrnamtType为SH表示股票PRN表示名义本金。cusip是证券唯一标识用来跨机构、跨期匹配。下面用lxml解析并转成 DataFrame。from lxml import etree import pandas as pd def parse_13f_xml(path: str) - pd.DataFrame: tree etree.parse(path) root tree.getroot() rows [] for info in root.iter(infoTable): row { issuer: info.findtext(nameOfIssuer), title: info.findtext(titleOfClass), cusip: info.findtext(cusip), value_k: float(info.findtext(value) or 0), amount: float(info.findtext(sshPrnamt) or 0), amount_type: info.findtext(sshPrnamtType), } rows.append(row) df pd.DataFrame(rows) if not df.empty: # 把 CUSIP 补成 9 位字符串方便后续匹配 df[cusip] df[cusip].astype(str).str.zfill(9) return df解析后的 DataFrame 可以直接保存为 CSV。df parse_13f_xml(data/raw/0001067983_2024-12-31_0001067983-25-000001.xml) df.to_csv(data/processed/example_holding.csv, indexFalse) print(df.head())有一点必须提醒value单位是千美元很多人第一次处理时会误当作美元。做规模对比时先用value_k * 1000转成原始美元再统一单位否则排行榜会差 1000 倍。另一个容易漏掉的细节是amount_type。在计算增减持时建议先按SH和PRN分组不能直接把两类数字加在一起。期权、权证、债券等非股票类资产需要单独看。6. 两期调仓对比增持、减持、新进、清仓有了两个季度的持仓明细下一步是计算调仓。调仓的分类一般有四类增持、减持、新进、清仓。判断逻辑新进本期有该 CUSIP上期没有。清仓本期没有该 CUSIP上期有。增持两期都有本期股数大于上期。减持两期都有本期股数小于上期。下面给出一个通用实现。def calculate_position_changes( prev_df: pd.DataFrame, curr_df: pd.DataFrame, price_mode: str amount, ) - pd.DataFrame: 根据两期持仓计算调仓。 price_mode: amount 表示按股数比较value 表示按市值比较。 if price_mode amount: prev_col, curr_col amount, amount else: prev_col, curr_col value_k, value_k merged prev_df.merge( curr_df, on[cusip, title], howouter, suffixes(_prev, _curr), indicatorTrue, ) merged[change_type] merged[_merge].map({ left_only: 清仓, right_only: 新进, both: 持仓不变, }) both_mask merged[_merge] both merged.loc[both_mask (merged[f{curr_col}_curr] merged[f{prev_col}_prev]), change_type] 增持 merged.loc[both_mask (merged[f{curr_col}_curr] merged[f{prev_col}_prev]), change_type] 减持 return merged合并时用outer方式可以保留只出现在某一期的股票。_merge字段来自 pandasleft_only表示只在上期出现right_only表示只在本期出现both表示两期都有。匹配键需要注意只使用cusip会导致不同份额类别合并例如同一公司的 A 类和 B 类股票。建议加上title一起匹配必要时还要区分amount_type。调仓计算完成后可以按市值变化大小排序找出最受关注的新进和清仓标的。result calculate_position_changes(prev, curr, price_modevalue) # 只看市值变化绝对值最大的持仓 result[value_change] result[value_k_curr].fillna(0) - result[value_k_prev].fillna(0) top_moves result.reindex(result[value_change].abs().sort_values(ascendingFalse).index) print(top_moves.head(20))这就是“调仓地图”的核心表格。有了它就能对单个机构的持仓变动做进一步分析。7. 可视化“钱流向哪里”板块与个股热力图表格适合精确比较但“钱流向哪里”这个问题用图更直观。这里提供两张图的思路。第一张图是行业/主题占比堆叠图。问题是怎么判断某只股票属于哪个行业这里没有直接用行业分类库而是用关键词映射。AI 基建、AI 应用、算力、半导体这些主题按发行人和 CUSIP 前缀做规则映射即可。精度不高但用来发现资金方向足够了。import matplotlib.pyplot as plt theme_map { NVDA: AI 芯片, AMD: AI 芯片, MSFT: AI 应用, GOOGL: AI 应用, META: AI 应用, TSM: 半导体代工, AVGO: 半导体设计, ASML: 半导体设备, } def add_theme(df: pd.DataFrame) - pd.DataFrame: df df.copy() df[theme] df[issuer].map(theme_map).fillna(其他) return df这里的issuer是发行公司名称不是股票代码。实际使用时需要一张“公司名 - 股票代码 - 主题”的映射表。更稳妥的方式是先把 CUSIP 映射到股票代码再映射到主题。第二张图是机构调仓热力图。用 matplotlib 画成横向条形图横轴是调仓市值变化纵轴按股票代码展示。def plot_top_changes(result: pd.DataFrame, top_n: int 20): top result.reindex(result[value_change].abs().sort_values(ascendingFalse).index).head(top_n) top top.sort_values(value_change) plt.figure(figsize(10, 8)) colors [#d62728 if v 0 else #2ca02c for v in top[value_change]] plt.barh(top[cusip], top[value_change] / 1000, colorcolors) plt.xlabel(市值变化百万美元) plt.title(13F 调仓市值变动 Top20) plt.tight_layout() plt.savefig(output/top_moves.png, dpi150)如果想把“地图”做得更丰富可以用 pyecharts 生成交互式 sankey 图或地图散点图。pyecharts 适合展示资金从机构流向标的的路径但依赖项较多这里不强制使用。先跑通 matplotlib 静态图已经能满足大部分技术验证需求。再进一步还可以把多期数据一起放进来绘制某个机构连续 4 个季度在 AI 主题上的市值占比变化曲线。这能回答“AI 躺赢时代是否真的结束”这类问题不用看新闻标题看占比趋势就行。8. 批量处理与工程化注意事项分析单个机构容易真正有价值的是批量处理几十家机构。批量处理时有几个工程问题必须先解决。第一个是请求频率。SEC EDGAR 官方要求请求方必须提供可识别的 User-Agent并且建议请求速率不超过每秒 10 次。稳妥起见每次请求之间加 0.2 秒延时。import time def safe_get(url: str, headers: dict, retries: int 3): for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout30) if resp.status_code 200: return resp time.sleep(0.5) except requests.exceptions.RequestException: time.sleep(1) return None第二个是缓存。同一个 CIK 的 submissions JSON 不需要每次都重新下载。建议落盘缓存文件名按 CIK 命名。重复跑脚本时直接读缓存减少对 SEC 的请求。import os import json CIK_CACHE data/raw/cik_cache def load_cik_from_cache(cik: str) - dict | None: path os.path.join(CIK_CACHE, f{cik}.json) if os.path.exists(path): with open(path, r, encodingutf-8) as f: return json.load(f) return None def save_cik_cache(cik: str, data: dict): os.makedirs(CIK_CACHE, exist_okTrue) path os.path.join(CIK_CACHE, f{cik}.json) with open(path, w, encodingutf-8) as f: json.dump(data, f)第三个是失败重试和日志。批量任务建议每处理一个机构都打印进度并记录失败原因。13F 文件偶尔会出现格式不规范、缺少infoTable节点、字段为空等情况。解析时要对单条数据做防御。def safe_parse_13f(path: str) - pd.DataFrame | None: try: df parse_13f_xml(path) if df.empty: print(f空持仓: {path}) return None return df except Exception as e: print(f解析失败: {path}, 错误: {e}) return None第四个是内存。几十家机构的数据量并不大但如果在循环里反复调用 merge会产生数量可观的中间对象。建议每处理一个机构就落盘一个 CSV最后再统一读取汇总避免因为单个环节异常导致整个任务重跑。批量处理的数据流设计如下机构名单 - CIK 查询 - 获取提交历史 - 批量下载 - 解析持仓 - 两期对比 - 输出 CSV 和图表这里不推荐把所有步骤写在一个超大脚本里。建议拆成download.py、parse.py、analyze.py、visualize.py四个脚本通过中间文件传递数据。这样某一步出错时不需要从头再来。9. 常见问题与排查清单问题现象可能原因排查方式解决方案请求 SEC EDGAR 返回 403User-Agent 缺失或格式不规范查看响应头替换为带联系邮箱的 User-Agent设置HEADERS {User-Agent: YourName/1.0 (your_emailexample.com)}找不到目标机构的 CIK机构名称不完整或公司改名在 SEC EDGAR 页面人工搜索尝试历史名称用 CIK 编号替代名称不依赖名称匹配下载的 XML 无内容链接拼接错误或文件类型不是 XML检查 accession 是否去掉连字符打开文件确认内容拼接 URL 时使用accession.replace(-, )解析后 DataFrame 为空XML 根节点不是informationTable查看文件前 50 行确认根节点名称在root.iter()中尝试多个可能的节点名value 单位对不上千美元被当成美元检查字段值大小和公开页面数据核对统一按value_k * 1000转成美元计算增减持出现异常值未区分 SH 和 PRN查看amount_type分布按amount_type先分组再计算两期 CUSIP 匹配不上CUSIP 前导零被 pandas 自动去除用str.zfill(9)或设置 dtype 为字符串加载 CSV 时指定dtype{cusip: str}批量运行时被限流请求间隔过短查看日志中的 HTTP 状态码和响应时间增加 sleep 间隔增加重试退避分析结果和新闻对不上报告期错位核对报告日期13F 是季度末后 45 天内披露分析时用报告期字段不用下载日期这批问题里最容易忽略的是数据口径不一致。遇到结果离谱时先检查字段类型和单位再检查是否混入了非股票类证券。10. 从一个案例到一套可复用的调仓监控13F 是季度数据天然适合做成周期任务。每次新季度披露后跑一遍脚本生成对比表就能持续跟踪重点机构的调仓方向。一个可复用的监控项目建议包含这几个文件13f_map/ ├── scripts/ │ ├── download.py # 批量下载 XML │ ├── parse.py # 解析并落盘 CSV │ ├── analyze.py # 两期调仓对比 │ └── visualize.py # 生成图表 ├── config/ │ └── institutions.yaml # 重点关注机构名单 ├── data/ │ ├── raw/ │ ├── processed/ │ └── cik_cache/ ├── output/ └── run_all.shinstitutions.yaml是一个重点机构配置文件示例内容如下institutions: - name: Berkshire Hathaway cik: 0001067983 - name: Bridgewater Associates cik: 0001350694 - name: Two Sigma Investments cik: 0001179394这里的 CIK 是该机构的公开编号实际使用时要先在 SEC EDGAR 上确认。配置文件的思路是机构名单是易变信息代码逻辑和名单分离。run_all.sh是批量执行入口#!/bin/bash python scripts/download.py python scripts/parse.py python scripts/analyze.py python scripts/visualize.py echo 调仓地图已生成请查看 output 目录在 Windows 环境下可以改成run_all.bat。只要脚本设计成每步都读中间文件平台切换不会太痛苦。更进一步的自动化可以引入 AI 辅助分析。批量处理几十家机构的 13F 后会得到一张几千行的调仓对比表。这张表直接看不直观可以先用规则提取 Top 20再用大模型做摘要。但要注意大模型只能做信息整合不能替代人工复核。尤其是涉及 CUSIP 匹配、行业分类、非股票类资产换算这些环节人必须确认。11. 合规与风险提醒13F 是公开数据但使用公开数据不代表可以随意解读。这里有几点必须遵守。第一所有分析都要基于报告人已经披露的信息。不能结合任何内幕消息、非公开调研信息或未经证实的传闻。第二13F 存在天然缺陷时滞、空头缺失、多策略合并、名义本金差异。基于这些数据做投资决策属于“信息不完整下的判断”要明确风险和不确定性。第三如果有人脸、声音、图像、数据抓取相关的技术需求也要注意素材和数据的授权边界。本文提到的 SEC EDGAR 抓取只涉及公开金融数据不涉及个人隐私信息但仍需遵守 SEC 的请求频率和合理使用规则。第四对外发布分析结果时要注明数据来源、报告期和处理方法不要使用绝对化表述不要承诺收益。12. 总结与下一步13F 调仓地图的价值不在“预测”而在“验证”。当讨论 AI 叙事是否开始退潮时与其听市场情绪不如把顶级机构的季度持仓拉出来按行业、按个股、按仓位变化画一张图看看钱的实际去向。从技术实现看这套流程并不复杂批量下载 XML、解析持仓明细、按 CUSIP 做两期对比、按主题做聚合可视化全部用 Python 就能完成。建议第一次做的时候先选一家你比较熟悉的机构跑通单季度解析和两期对比再扩展到几十家机构的批量任务。最容易踩坑的地方是数据口径CUSIP 前导零、value 千美元单位、SH 和 PRN 的区分这三类问题能解决掉基本就能跑通整条链路。下一步可以继续扩展的方向有三个一是接入更多数据源比如公司基本面数据、行业分类映射提高调仓地图的业务含义二是把分析脚本做成定时任务每季度更新一次三是引入 AI 摘要自动生成机构调仓的解读报告。整个过程不需要太高硬件门槛一台普通电脑加上 Python 环境就够。建议先跑通最小的闭环再逐步增加复杂度。