有段时间我每天开盘前第一件事就是把当天的ETF申赎清单拉出来按照篮子净值给手里的策略重新排一遍队。很多人觉得申赎清单这种数据只有基金公司和做市商才用得上散户搞量化根本不用碰这个想法其实会错过很多信号。作为《qmt量化交易策略小白学习笔记》系列的第67期这篇文章就围绕QMT编程里一个非常具体、但经常被忽略的功能——获取ETF申赎清单——从环境准备、接口调用、代码实现到实战踩坑完整过一遍。内容不绕弯子适合Python基础不牢、刚把QMT跑起来的小白直接抄作业。1. 为什么量化策略要盯ETF申赎清单1.1 申赎清单里到底有什么申赎清单在基金圈里叫PCF全称Portfolio Composition File翻译过来就是投资组合构成文件。每天收盘后或者第二天开盘前基金公司会公布当天申购赎回ETF时那一篮子股票到底包含哪些、各多少股、哪些股票允许现金替代。可以把它想象成一张配方表想生产一份ETF产品你就得按照这个配方把原材料备齐。举个典型例子某只沪深300 ETF的申赎清单里常见字段大致是这些字段含义实战中怎么看证券代码成分券代码判断篮子里的标的范围证券名称成分券名称辅助核对代码用证券数量每篮子对应股数计算篮子价值最核心的字段现金替代标志允许/禁止/必须现金替代决定某只股票能否用现金补足现金替代溢价比例用现金替代时额外加收的比例直接拉高实际申购成本预估现金差额申赎时找零的现金部分算净值时必须加上的尾巴最新价成分券的实时行情结合数量算出一篮子实时价值有了这份配方你就能算出ETF的实时净值再和它盘中的交易价格做对比天然就构成了一个套利监控闭环。以前不做ETF这条线的时候我觉得这东西离自己很远真正开始写策略才发现很多超额收益信号恰恰藏在这些每日变动的清单里。1.2 申赎清单在量化策略里的三个典型用途第一个用途是折溢价套利。ETF既可以像股票一样在二级市场买卖也可以在一级市场用一篮子股票申购赎回。当市价明显高于实时净值理论上可以买入一篮子成分股、申购ETF、再在二级市场卖出反过来当市价折价就买入ETF、赎回成一篮子股票、再卖出成分股。申赎清单就是计算实时净值的原料没有它你只能依赖行情软件里的IOPV延迟和精度都不可控。第二个用途是现金替代比例的监控。清单里会标出哪些股票禁止现金替代、哪些允许现金替代还会有一个现金替代溢价比例。对做套利或者做篮子交易的策略来说这个比例直接决定你实际要准备多少现金、申购成本到底是多少。我见过不少朋友只看成分股数量不看现金替代标志结果按清单模拟出的净值跟真实成本差了一截最终套利窗口看着开了实际一算利润全被溢价吃掉了。第三个用途偏向组合管理。申赎清单每天都在更新对比连续几天的清单能看到基金在调仓、权重在变化甚至能通过现金替代标志的调整来判断成分股流动性出现了什么问题。这些信息对中低频的行业轮动策略也有参考价值比如你发现某只成分股被改成必须现金替代往往意味着它停牌或者流动性出问题了这时候相关板块的ETF也要多留个心眼。这里必须提醒一句一级市场申赎对资金量有门槛很多ETF要求50万份或者100万份起步小账户不一定能直接做套利。但清单数据的价值不在于让你天天去申赎而在于它是一份高信噪比的数据源可以衍生出很多信号比如折溢价回归策略、流动性预警、基金调仓跟踪等等。数据本身是免费的不利用起来就亏了。2. 动手前的环境准备QMT连接与接口梳理2.1 QMT的Python环境到底怎么配QMT自带了Python接口也就是xtquant库。第一次接触的朋友最容易卡在“到底怎么连上客户端”这一步。常规做法是找到你本机QMT安装路径下的userdata_mini目录这个路径是客户端账户数据的存放位置也是程序身份识别的关键。然后用XtQuantTrader(path, session_id)创建对象session_id自己生成一个不重复的数字就行一般用当前时间戳。我建议直接用QMT终端自带的那个Python环境来跑脚本省去版本不匹配的麻烦。如果想在自己电脑的其他Python环境里跑那就pip install xtquant安装好后也要保证版本和终端匹配。关于安装环境依赖网上说法很多其实没那么玄学重点就是两件事一是xtquant库能正常import二是连接路径指向userdata_mini。我吃过一次亏图省事复制了同事的路径结果连了半天都是失败后来才发现不同账户的userdata_mini位置不一样。连接流程本身很简单核心代码长这样from xtquant.xttrader import XtQuantTrader from xtquant.xttype import StockAccount import time # 本机QMT数据目录以你实际安装路径为准 path C:/你的券商QMT路径/userdata_mini session_id int(time.time() * 1000) trader XtQuantTrader(path, session_id) trader.start() # 用你的资金账号创建股票账户对象 acc StockAccount(你的资金账号) connect_result trader.connect() if connect_result 0: print(连接成功) trader.subscribe(acc) else: print(连接失败错误码, connect_result)connect()返回0才代表成功subscribe用来订阅账户信息。很多小白以为start()之后就算连上了其实connect这一步才是真正握手返回值非0就要去排查路径和客户端登录状态。2.2 与申赎清单相关的接口怎么梳理不同券商提供的QMT版本接口命名和开放程度并不完全一致。有的版本在数据中心里专门提供申赎清单导出有的则需要通过行情快照和成分股数据间接计算。我在实践里的处理思路是先把QMT已经开放的常用数据接口用熟再根据自己版本的实际能力去组装申赎清单。常用的接口有几个。xtdata.get_instrument_detail可以获取合约基础信息能确认你拿到的代码是不是ETF以及它的申赎代码是哪一个xtdata.get_snapshot可以拿到实时快照包括最新价和涨跌幅xtdata.get_stock_list_in_sector可以按板块拉成分股列表做篮子对比时很方便。这些接口虽然不叫“get_pcf”但配合申赎清单文件足够把整套逻辑跑通。有个小技巧打开QMT客户端去数据或者行情菜单里找一找看有没有“ETF分析”“申赎清单”之类的功能入口。有的话一般能找到一个可以导出的文件或者一个对应的数据目录。知道文件在哪之后编程就变成了读文件、解析字段的事情。如果连入口都找不到就先老老实实用行情数据接口做间接方案。别嫌麻烦搞清楚自己手里的版本支持什么比在网上搜半天“万能代码”靠谱得多。3. 核心代码实现从登录到拿到申赎清单3.1 连接QMT并初始化账号先上一个相对完整的初始化代码把前面提过的细节都放到一起。这个类可以反复用后面不管是拿行情还是拿申赎清单都基于这个连接对象来操作。from xtquant import xtdata from xtquant.xttrader import XtQuantTrader from xtquant.xttype import StockAccount import time class QMTClient: def __init__(self, qmt_path, account_id): self.path qmt_path self.account_id account_id self.trader None self.acc None def connect(self): session_id int(time.time() * 1000) self.trader XtQuantTrader(self.path, session_id) self.trader.start() self.acc StockAccount(self.account_id) result self.trader.connect() if result 0: print(QMT连接成功) self.trader.subscribe(self.acc) return True print(QMT连接失败错误码, result) return False def get_trader(self): return self.trader这个类每次实例化都会生成新的session_id避免重复会话冲突。connect成功后再去调行情接口或者交易接口就不会出现“client is null”这种莫名其妙的问题。有朋友问我为什么一定要subscribe其实subscribe本质是订阅账户资金和持仓变动如果只拿公开行情数据、不涉及自己的账户交易信息有些场景下不订阅也能跑但做策略回测和实盘衔接时建议还是订阅上方便后续扩展。3.2 获取申赎清单的两种方式第一种方式是部分QMT版本在数据接口里直接提供了申赎清单查询你可以先尝试调用看是否报错或返回空数据。不同版本接口名不同这里给一个示意性的调用过程核心是探测自己的版本支不支持def try_get_pcf_in_direct_way(etf_code): try: # 具体接口名请以自己QMT版本的文档为准 # 常见的形式类似于 get_etf_pcf / get_pcf_info info xtdata.get_etf_pcf(etf_code) # 示意 if info: return info except Exception as e: print(直接接口不可用尝试其他方式:, e) return None如果这段代码能跑出结果那就省事了。但坦率说不少朋友的版本可能不支持这种直接查询或者接口名完全不同。这时候就走第二种更通用的方式读取客户端导出的申赎清单文件。第二种方式的思路是先手动在QMT客户端里找到申赎清单导出功能导出一次文件看看格式是CSV、TXT还是Excel。然后写解析脚本把文件里的字段读成结构化数据保存成DataFrame方便后续计算。我这里用一个按逗号分隔的文本文件举例import pandas as pd def load_pcf_file(file_path): # 文件格式以实际导出为准这里假设是utf-8编码的CSV df pd.read_csv(file_path, encodingutf-8) required_cols [证券代码, 证券名称, 证券数量, 现金替代标志, 现金替代溢价比例, 预估现金差额] for col in required_cols: if col not in df.columns: print(缺少字段:, col) return None # 把证券代码统一成6位字符串后面和行情接口对接时不容易乱 df[证券代码] df[证券代码].astype(str).str.zfill(6) return df这里有个细节要注意资金账号和文件路径最好写成配置文件不要硬编码在策略里。还有编码问题Windows上导出的文件经常是GBK或者GB2312你能不能正确读出来直接决定后续解析是否成功。我建议导出后用文本编辑器看一眼编码再在read_csv里指定省得来回折腾。3.3 定时刷新与文件监控申赎清单不是一成不变的盘中可能因为成分股调整或者基金公司公告出现变化。虽然大部分更新集中在盘前但为了策略稳健还是建议做一个定时刷新机制。最简单的做法是每隔一段时间重新读一次文件比如用Python的schedule库或者干脆写个while循环里加time.sleep。import schedule import time def job(): df load_pcf_file(config/etf_pcf.csv) if df is not None: print(已刷新申赎清单股票数量:, len(df)) # 在这里继续做计算和监控 schedule.every(5).minutes.do(job) while True: schedule.run_pending() time.sleep(1)刷新频率不建议太激进盘前和盘中每5到10分钟一次足够用了。有些朋友每分钟拉一次结果文件本身没变白浪费资源还有可能触发券商的访问限制。另外如果QMT导出的文件是按日期生成的比如pcf_20250601.csv这种命名程序里要写好按当天日期拼接路径的逻辑别第二天跑起来还在读昨天的旧数据。4. 实战中一定会踩的坑4.1 client is null到底是怎么来的QMT报“client is null”算是高频问题很多第一次跑通连接的朋友都见过。它的直接含义是交易客户端对象为空也就是程序没有拿到真正可用的客户端实例。常见原因就那么几个一是连接路径不对path没有指向userdata_mini二是QMT终端没有登录或者登录后又被退出了三是session_id跟之前的会话冲突需要重新生成四是xtquant库版本和客户端版本对不上。我的排查顺序一般是这样现象可能原因解决操作connect返回非0路径错误打开客户端找到实际userdata_mini目录connect成功但调用时client is null客户端未登录或掉线重新登录QMT保证界面在线偶发client is nullsession_id重复用时间戳生成唯一session_id升级后出现库版本不匹配卸载xtquant用客户端自带版本重装这里补充一个我自己的经验QMT终端最好保持登录状态不要用那种自动休眠的模式。有一阵我为了省内存把客户端窗口最小化后系统自动睡眠第二天程序跑起来就报空指针折腾了一圈才发现是客户端掉线了。后来我在脚本启动时加了个简单的重连逻辑发现异常就自动重新connect一次。4.2 申赎清单拿不到、数据为空怎么办如果是读文件方式空空如也通常有三种原因。第一你选了一个非交易日文件还没生成路径自然找不到第二你的行情权限没开通客户端里虽然能看到界面但导出内容是空的第三ETF代码写错了有些ETF的申赎代码和交易代码不是同一个你得用申赎代码去查。我踩过最蠢的坑是把511990这种交易代码直接拿去查申赎清单结果怎么查都是空。后来打开客户端一看申赎代码是另一串数字瞬间就明白了。所以动手之前先在客户端里找到正确的申赎代码再写进配置。还有就是不要忽略合约类型的校验代码里可以加一步判断确认合约类型是ETF再往下走避免拿个股票代码去查清单闹笑话。4.3 字段解析错位的排查思路就算文件读到了解析也可能出问题。常见症状是列名对不上、数字列串位、中文乱码。总结下来主要是三个原因文件被Excel打开过然后保存格式变了导出文件其实用的是制表符或者竖线分隔不是逗号编码不是UTF-8。每次遇到这类问题第一步永远是拿文本编辑器直接打开原始文件看别上来就分析代码。还有一种情况是同一份文件在月初和月末的列数不一样。比如某些字段只在特定情况下输出导致read_csv时自动把后面几列推到了错误的位置。我现在的做法是读取后用columns做一次严格校验列数不对就报警停机而不是让脏数据悄悄流进策略里。数据质量直接决定策略质量这块多花点时间是值得的。5. 拿到申赎清单之后从数据到策略5.1 用申赎清单算一篮子价值拿到清单后最有用的一个动作就是算出一篮子股票的实时价值。有了它再对比ETF市价就能知道当前的折溢价状态。计算逻辑不复杂用清单里的证券数量去乘以股票最新价把所有股票的市值加总最后再加上预估现金差额。不过要注意现金替代标志为“必须现金替代”的股票它的市值部分不能直接按股票数量算要按现金替代的规则处理否则净值算出来是偏的。下面的代码演示一个简化版的计算def calc_basket_value(pcf_df, price_map): total_value 0.0 for idx, row in pcf_df.iterrows(): code row[证券代码] qty row[证券数量] flag row[现金替代标志] if flag 禁止现金替代 or flag 允许现金替代: # 股票数量申赎用最新价计算 price price_map.get(code, 0) total_value price * qty else: # 必须现金替代直接用预估替代金额 total_value row.get(预估替代金额, 0) # 加上预估现金差额 total_value pcf_df[预估现金差额].sum() return total_valueprice_map从哪里来用xtdata.get_snapshot批量拉取即可。实际操作里我一般先把成分股代码列出去重然后批量请求快照做成字典传给计算函数。这样整个流程在开盘期间大约几秒就能跑一轮完全够用。5.2 策略落地前必须想清楚的三件事第一件事是数据更新的时间点。申赎清单盘前更新盘中可能变动你的监控脚本要选择合理的刷新频率别在开盘瞬间集中请求。我自己的习惯是开盘前先拉一次作为基准盘中每5分钟看一次变化如果折溢价超过阈值再临时加密刷新频率。第二件事是成本核算。一级市场申赎有手续费、有冲击成本二级市场买卖也有佣金和滑点。清单计算出的理论利润扣掉这些成本之后可能所剩无几。很多套利新手亏钱不是看不准方向而是没算清楚成本觉得自己发现了稳稳的套利窗口结果真金白银进去就被摩擦成本教育了。第三件事是停牌和现金替代的处理。如果某个成分股停牌它大概率会变成必须现金替代这时你按实时价格算出来的篮子价值就失去了参考意义得用现金替代金额来重新估算。策略里一定要对这类状态做显式判断不要把异常数据当成正常信号用。处理不好这个细节回测再漂亮实盘也一样翻车。6. 写在最后的一点体会做量化交易的这几年我越来越觉得很多看似高级的策略最后拼的都是基础数据的处理能力。ETF申赎清单这种公开数据普通散户也能拿到但能不能高效地接入自己的程序、准确地解析、稳定地监控才是拉开差距的地方。第67期笔记把获取申赎清单的完整链路讲清楚了你可以先从一个小脚本开始每天记录一只ETF的折溢价变化跑上两周等自己对数据节奏熟悉之后再把它扩展成完整的策略模块。我自己就是这么一步步走过来的别看起步笨拙但每一步都算数。