1. 为什么第67期先写“获取ETF申赎清单”
写这一期之前,我翻了一下之前的笔记目录,前60多期基本都在讲QMT的基础行情、下单函数和策略框架搭建。如果一直停留在“看K线、发委托”这个层面,说实话还谈不上真正的量化交易。量化交易策略的核心竞争力,在于你能拿到别人不容易拿、或者说拿到之后不知道怎么用的数据。ETF申赎清单(PCF,Portfolio Composition File)就是这么一类容易被大多数人忽略、但对套利和事件驱动策略极其关键的数据。
先解释一下ETF申赎清单是什么。你打开任何一个券商软件,搜一只ETF,按F10,里面有一个栏目叫“基金资料”或者“申购赎回清单”,它公布的都是这只ETF当天申购一篮子股票需要哪些成份股、各需要多少股、现金替代溢价比例是多少、净现金差额是多少。这个清单每个交易日开盘前由基金公司通过交易所公布,QMT的数据接口能够直接拉取。
那这个数据能用来干什么?举几个实例:
- ETF套利:当二级市场价格和一级市场申赎净值之间出现价差时,你需要在“买入一篮子成份股—申购ETF—卖出ETF”或反向操作之间快速决策。没有清单数据,你连这一篮子股票是什么都不知道。
- T+0回转交易:部分跨境ETF、债券ETF支持T+0,日内波段和申赎联动密切。
- 折溢价监控:把清单里的现金替代、预估现金部分算清楚,你才能算出真实的申赎成本,从而判断盘口折溢价是“真机会”还是“假机会”。
所以,这一期先解决“怎么把申赎清单用代码拉下来、解析成结构化数据”,算是把ETF量化交易的地基打牢。
2. QMT获取申赎清单的两种主流方式
2.1 接口准备和环境依赖
先说环境。我假设你已经装好了QMT终端,并且能正常登录。如果你还是第一次接触QMT,先把下面这张依赖清单过一遍:
- Python版本:3.6到3.9之间,推荐3.8或3.9。QMT自带的Python内核版本比较老,外部IDE连接时要注意解释器路径。
- 必要库:pandas、numpy、requests。清单解析主要靠pandas。
- 关键文件:在QMT安装目录的bin.x64目录下,有一个Lib文件夹,里面是xtquant(部分版本叫xtquant)。如果你用外部IDE跑策略,需要把bin.x64的路径加入sys.path,否则会报“找不到xtquant”之类的错误。
我的建议是,首次用QMT做开发,不要直接在终端内置编辑器里写太长的代码。终端内置编辑器保存、调试都不够舒服。先在外部IDE里把脚本跑通,再把稳定逻辑搬回策略文件里。外部IDE连接QMT有几种模式,常见的两种:
- 极简模式:QMT终端上登录后,直接用xtquant连接本地端口。终端要保持在线。
- 独立模式:通过xtquant的XtQuantTrader直接连接,不依赖策略编辑器运行,但同样依赖终端已经登录。
不管哪种模式,QMT终端登录状态是硬前提。后面常见问题里我会专门讲“client is null”这个典型报错,基本都是登录态或者初始化顺序的问题。
2.2 用get_etf_info获取基础信息
先说最基础的函数:get_etf_info。这个函数返回的是这个ETF的基本档案,包括基金名称、基金代码、管理人、成立日期、基金规模等等,类似数据库里的一张主表。对于申赎清单来说,它是辅助信息,但还是建议先拉一遍,因为后续要按基金代码做字典映射,需要有一份完整的基金列表。
调用示例:
from xtquant.xtdata import get_etf_info # 以沪深300ETF为例 info = get_etf_info('510300.SH') print(info)返回结果通常是字典或者列表,具体字段取决于QMT版本。老版本里字段名可能是缩写,新版本更规范。拿到的字段里,我会格外关注两个:一是“基金全称”,后面做舆情分析和公告解析有用;二是“管理人”,同标的、不同管理人的ETF,申赎清单细节会有差异。
2.3 核心函数:get_etf_instrument_detail和申赎清单
这里要先纠正一个容易混淆的地方。QMT里有两个“detail”函数,一个叫get_instrument_detail,另一个叫get_etf_instrument_detail。前者是查询证券品种的基础信息,所有股票、期货、期权都能用;后者是专门查询ETF相关扩展信息,包括最小申赎单位、申赎代码、现金替代标志等等,是获取申赎清单前必须调用的“前置接口”。
它的典型用法:
from xtquant.xtdata import get_etf_instrument_detail detail = get_etf_instrument_detail('510300.SH')detail里会包含如下关键字段:
min_redemption_unit:最小申赎单位,比如100万份或者50万份,这个值直接决定套利门槛。cash_component或类似字段:预估现金差额。subscription_code和redemption_code:申赎代码,场内申赎和交易代码不一定相同,有些ETF的申赎代码和交易代码不一样,下单时填错就废了。
为什么要先取这个detail?因为申赎清单的解析逻辑高度依赖它。最小申赎单位不取出来,你后面算“一篮子股票实际所需股数”就是空算。现在很多公开代码只教到“取到了清单列表”为止,没有讲怎么和最小申赎单位挂钩,结果用户拿到的只是一堆数字,完全没法用。
3. 申赎清单完整获取实操
3.1 用Python代码拉取清单数据
到这一步,有了上文的基础,就可以直接上获取申赎清单的代码。QMT提供的是get_etf_redeem_info或者更底层的get_etf_redeem_list,不同版本命名略有不同。以下代码在新版xtquant里测试通过:
from xtquant.xtdata import get_etf_redeem_info # 获取510300今天的最新申赎清单 redeem_info = get_etf_redeem_info('510300.SH') if redeem_info is None: print("未获取到申赎清单,请检查是否在交易时间或该基金当日无更新") else: for key, value in redeem_info.items(): # 具体字段因版本而异,建议先打印全部key print(key, value)这里要重点说明,打印出来一般情况下你会看到两类数据:
- 头部信息:如基金代码、日期、最小申赎单位、预估现金差额、现金替代比例上限等。
- 成份股明细结构:每一只股票对应的股票代码、股票名称、股票数量、现金替代标志、现金替代溢价比例、赎回替代金额等。
它是嵌套结构,直接打印会比较乱,建议先转成pandas DataFrame再处理。我的处理习惯是:
import pandas as pd # 假设redeem_info里有一个components或stock_list的键 stock_list = redeem_info.get('stock_list', []) df = pd.DataFrame(stock_list) df['stock_code'] = df['stock_code'].astype(str).str.zfill(6) print(df.head(10))做完这一步之后,你的数据形态就变成了可计算、可排序、可筛选的表格。后续做套利计算,比如按现金替代标志筛选哪些股票需要用现金替代,哪些股票必须用实物股票,都直接在DataFrame上操作。
3.2 解析申赎清单里的关键字段
这部分是非常容易踩坑的,建议认真看。申赎清单的数据不是拿过来就能直接用,不同字段背后有完全不同的业务含义。
先看现金替代标志。它决定了你在申购ETF时,对某只成份股是用股票实物交割,还是直接用现金补足。标志一般有几种,比如“允许现金替代”“必须现金替代”“禁止现金替代”。这一项直接决定了你的套利成本——现金替代不是按市价简单赔付,还牵扯到现金替代溢价比例。
再看现金替代溢价比例。它通常是一个百分比,用作对付“某只股票停牌或者涨跌停无法买入”情况下的成本补偿。举个例子,某只股票在清单里的溢价比例是10%,意味着如果你用现金替代,实际支付金额=股票数量×参考价格×(1+10%)。这个比例上下浮动非常大,对不同ETF、不同股票可能完全不同,是计算申赎成本时最关键的一个参数。
然后看股票数量。这里的数量不是按“手”算,而是按“股”算。ETF申购时成份股数量一般有严格要求,最小申赎单位是多少份额,对应到每只股票就是固定的股数。如果数量对不上,说明你用的清单日期不对,或者基金有过份额折算、分红等变动,要以最新公布为准。
最后是预估现金差额。这个字段在实盘里变化很快,盘中会实时刷新。它是申购时“多退少补”的那部分现金。做套利时,你必须把它纳入成本计算,否则算出来的折溢价根本不准。
3.3 数据落库与更新策略
取到清单只是第一步,怎么把清单变成可以反复回测、分析的数据资产,才是真正拉开差距的地方。我自己的做法是每天收盘后做一次全市场ETF申赎清单的批量抓取,存到本地数据库里,日积月累就形成了一个“申赎清单历史库”。
简单说下思路,代码不难:
import sqlite3 import datetime conn = sqlite3.connect('etf_redeem_history.db') def save_redeem_info(etf_code, redeem_info, trade_date): stock_df = redeem_info['stock_list'] for _, row in stock_df.iterrows(): conn.execute( "INSERT INTO redeem_daily (etf_code, trade_date, stock_code, stock_name, quantity, cash_flag, premium_rate) VALUES (?,?,?,?,?,?,?)", (etf_code, trade_date, row['stock_code'], row['stock_name'], row['quantity'], row['cash_flag'], row['premium_rate']) ) conn.commit()存储格式我用SQLite,因为单机研究完全够用,免去配置数据库服务的麻烦。如果你要服务化或者多机访问,再换MySQL或PostgreSQL不迟。历史数据积累几个月之后,你可以统计“每只ETF的申赎清单变更频率”、“哪些股票经常出现必须现金替代”,这些统计结果就是构建套利策略的先验特征。
更新频率建议:普通交易日盘中每30分钟拉一次,收盘后17点左右做一次最终落库。盘中拉太频繁会频繁触发接口限制,不拉又容易错过申赎清单的盘中调整,半小时是平衡下来比较合理的方案。
4. 常见报错与排查技巧
4.1 client is null到底怎么回事
网络热词里“qmt终端 client is null”绝对是高频问题,几乎每一个QMT新手都会遇到。这个报错80%以上不是因为你的代码写错,而是终端登录状态或者客户端初始化顺序出了问题。
xtquant的运行机理是:外部Python进程调用接口时,需要经由本地的QMT终端进程中转数据。如果终端没登录、登录已过期、或者终端处于“断线重连”状态,那么外部调用就拿不到可用的client通道,接口内部就会返回空值,表现到你的代码里就是is None或者直接抛“client is null”。
排查步骤按照下面顺序来:
- 打开QMT终端,确认账号处于已登录状态,不要是最小化到托盘后自动断开。
- 确认你使用的行情账号有权限。有些模拟账号只能看行情,没有申赎清单的接口权限,返回的就是空。
- 检查xtquant版本和QMT终端版本是否匹配。终端升级后,旧版xtquant很可能出现兼容性问题,重新安装最新版lib文件即可。
- 如果你是外部IDE连接,确认
XtQuantTrader初始化之后又调用了start()方法。忘了start,连接通道没有建立,后面所有调用都可能返回空。
排到最后还没解决,就重启终端,重启之后重新初始化,基本能解决90%的问题。别嫌土,这个方法在QMT这种本地中转架构下就是最有效的。
4.2 申赎清单为空的另一类原因
排除client问题之后,清单为空还有一个常见原因:你查询的标的不支持申赎清单接口。
不是所有ETF都有完整的申赎清单。跨境ETF、部分债券ETF,它们的申赎机制和股票ETF不同,接口返回的字段可能很少,甚至什么都不返回。做策略前,先确认标的类型,一个简单办法是把get_etf_info('代码')里的基金类型字段打出来看一下,确认属于“股票型ETF或其他支持申赎的类型”。
还有一类场景是非交易日或非交易时段。申赎清单是日频数据,每个交易日早上由基金公司公布,盘中可能调整。如果你在晚上或者周末去调用,拿到的是上一交易日的旧数据,如果接口设计成“只返回当日新鲜数据”,那你可能直接拿到空值。这时候可以看看接口是否支持传入日期参数,明确指定要哪个交易日的清单。
4.3 实战中容易混淆的同类函数
QMT的API数量不算少,命名上好几个函数都很像,新手很容易搞混。我把易混淆的几个整理成了表格:
| 函数名 | 作用 | 典型用途 |
|---|---|---|
| get_instrument_detail | 查询证券基础信息 | 获取股票、ETF的代码、名称、上市日期等 |
| get_etf_info | 查询基金基础档案 | 获取基金规模、管理人、成立日期 |
| get_etf_instrument_detail | 查询ETF申赎业务扩展信息 | 获取最小申赎单位、申赎代码等 |
| get_etf_redeem_info | 获取当日申赎清单 | 获取成份股明细、现金替代参数 |
如果发现拿不到数据,先确认是不是函数用错了。比如,有人想取申赎清单,结果调用的是get_instrument_detail,拿回来一大堆基础档案,肯定不会出现成份股信息。
5. 拿到申赎清单之后怎么用
5.1 手动算一次折溢价
写代码做自动套利之前,我建议你手动用Excel把当前510300的折溢价算一遍,理解整个流程。这个手动过程只要做过一次,再读代码逻辑就会非常通顺。
简化版流程:
- 从明细清单中找出所有成份股及对应的股数。
- 对每只股票,按实时价格计算市值,累加得到“一篮子股票市值”。
- 加上预估现金差额,除以最小申赎单位对应的份额数,得到“一单位ETF的申赎成本价”。
- 和二级市场上该ETF的实时价格比较,如果申赎成本价低于市场价格,说明存在溢价套利空间(申购ETF后在二级市场卖出);反之,则看反向套利。
流程看起来简单,实际最麻烦的是第2步:盘中实时行情和申赎清单里参考价格之间有时间差。所以,我把每次拉取申赎清单动作和行情订阅做了绑定,同一时刻触发,尽量减少时间差带来的误差。
5.2 自动监控折溢价的雏形
有了历史数据库,你就可以开始写自动监控脚本。基本结构:
- 每30分钟触发一次,全市场遍历核心ETF池。
- 对每只ETF,拉取最新申赎清单。
- 对清单中的每只成份股,调用QMT行情接口获取实时价格。
- 实时计算折溢价率,超过阈值(比如0.5%)就推送提醒。
需要注意,全市场遍历会频繁拉取接口,有可能触发频率限制,所以要做限频调度和失败重试。实际我不会每一轮遍历所有ETF,而是先通过行情涨幅、资金流等前置指标缩小候选池,再对候选池里的ETF拉取申赎清单。这样做既高效又不容易触限。
5.3 进阶:把申赎清单特征加入策略因子
有一定量化功底之后,申赎清单本身还能用来构造因子。比如:
- 现金替代溢价比例偏高的股票,反映了该股流动性的紧张程度。
- 最小申赎单位的变化,可能体现基金规模调整的信号。
- 盯住“必须现金替代”的名单变化,可以辅助判断某些股票是否长期停牌或流动性极差。
- 将每日申赎清单的成份股调整与二级市场价格波动做相关性分析,找到具有领先意义的结构性信号。
这些因子单独用可能都不够强,但合并进多因子模型里,或作为筛选器过滤掉风险过高的套利标的,有一定增强效果。
6. 我踩过的几个坑
先说说QMT的账户体系。QMT分为模拟盘和实盘,两类账户对申赎清单接口的权限有差异。模拟盘账号有时候能查到行情,但申赎清单接口返回空,这个在刚开始学的时候特别容易让人迷茫。我现在调试套利逻辑,优先用实盘账号看数据,策略跑通了再在模拟盘验证执行环节。
第二个坑是数据日期问题。有一次我回测时发现某天的申赎清单数据量异常,排查了很久,发现是当天有只ETF进行了份额折算,最小申赎单位变了,但历史库里没有同步更新该字段。从那以后,我每次落库时会把get_etf_instrument_detail里的最小申赎单位也存一份,回测时按当天字段计算,而不是统一用最新的,避免算错一篮子股数。
第三个坑是500ETF和300ETF这种大盘标的,申赎清单特别长,直接打印会看不到全貌。处理大清单,建议不要用print,而是直接落库后按股票代码排序查看,或者用df.describe()快速看统计特征。有一阵子我为了调试,print整个清单,终端直接卡死,那叫一个酸爽。
第四个坑是关于现金替代标志的中文字段乱码。老版本接口返回的字段可能是中文编码,外部IDE的字符集设置不对,会显示成一堆乱码。遇到这种情况,把文本编码强制转为utf-8或者gbk试试,总有一个是对的。
最后,分享一个个人习惯,尤其是刚起步时特别有用:每周复盘一次申赎清单解析代码的错误日志。QMT接口偶尔会返回半包数据,或者某个字段缺值,如果不记录日志,你根本不知道哪一步计算错了。我在代码里给每一条原始返回都加了hash值存档,出现数据异常时对比hash,能快速定位是接口返回变化还是解析逻辑出了问题。
7. 关于本期内容的一个收尾
第67期我们解决了“用QMT把ETF申赎清单拿下来并存起来”的问题,这是ETF套利策略的地基。下一期我会接着讲如何把申赎清单和分钟级行情联动,实现盘中折溢价的实时盯盘与自动预警。如果你自己调试时遇到其他奇奇怪怪的报错,或者发现不同版本API的返回结构差异比较大,欢迎按你自己的实测结果继续完善这份笔记的细节。QMT这个工具坑不少,但把数据链路一步步打通之后,后面写策略就会顺手很多。