1. 从数据源到因子库这条链路为什么值得单独拎出来讲做量化的人都有一个共识策略的收益上限在数据落库那一刻就基本定死了。你可以有很漂亮的因子表达式、很精细的模型调参但如果底层数据管道是断的、字段是对不上的、时间轴是歪的后面所有工作都是在流沙上盖楼。Qlib 作为一套面向量化研究的开源框架它本身不生产数据它消费的是结构化的、带明确 schema 的二进制数据。而 tushare 这类数据接口提供的是原始的、宽表式的、字段命名各异的 DataFrame。这两者之间隔着一道鸿沟这道鸿沟就是今天要聊的核心——ETF 数据从 tushare 下载、清洗、转换最终导入 Qlib 标准数据格式的完整链路。这个内容适合谁如果你刚开始接触 Qlib跑通了官方 demo 但不知道怎么把自己的数据塞进去这篇就是写给你的。如果你已经在用 Qlib 做股票研究想扩展到 ETF 轮动、大类资产配置但卡在数据准备环节这篇同样适用。甚至你只是想理解“一个量化框架的数据层到底长什么样”把这条链路走一遍比看十篇架构文档都管用。我自己的经历是第一次往 Qlib 里导数据的时候光搞清楚它要的.bin文件结构和instruments配置就花了大半天中间踩的坑包括日期格式不匹配、字段顺序错位、复权处理遗漏等等。所以这篇不会只给你一段能跑的代码而是把每一步“为什么这么做”讲透让你下次遇到别的数据源也能自己迁移。2. 先搞清楚 Qlib 到底想要什么样的数据2.1 Qlib 的数据分层原始层、特征层、数据集层很多人一上来就急着写转换脚本结果写到一半发现不知道目标格式长什么样。我们先把 Qlib 的数据组织方式拆开看。Qlib 的数据大致分三层第一层是原始行情层也就是日历数据、标的列表、以及每个标的的 OHLCV 等基础字段。这一层决定了“有哪些天、有哪些标的、每个标的每天长什么样”。第二层是特征层Qlib 会把原始字段加工成表达式引擎能识别的特征比如$close、$volume以及你自定义的因子。这一层是策略研究员日常打交道最多的地方。第三层是数据集层也就是把特征按时间窗口切成训练集、验证集、测试集供模型消费。我们今天要做的是把 tushare 的 ETF 数据落到第一层和第二层。具体来说Qlib 期望的目录结构是这样的qlib_data/ ├── calendars/ │ └── day.txt ├── instruments/ │ └── etf.txt └── features/ └── instrument/ ├── open.day.bin ├── high.day.bin ├── low.day.bin ├── close.day.bin ├── volume.day.bin └── factor.day.bin这里有几个关键点必须提前说清楚。calendars/day.txt是一个纯文本文件每行一个交易日格式是YYYY-MM-DD。instruments/etf.txt每行是“标的代码 起始日期 结束日期”用制表符分隔。features下面每个标的一个文件夹文件夹名就是标的代码里面每个字段一个.bin文件是浮点数数组的二进制存储。注意Qlib 的.bin文件不是随便写的二进制它默认是float32小端序而且每个文件对应一个字段在日历上的完整时间序列。如果你的日历有 1000 天那每个.bin文件里就应该有 1000 个浮点数缺失值用NaN填充不能跳过。2.2 为什么不用 CSV 直接喂给 Qlib有人会问Qlib 不是也支持从 CSV 读取吗为什么还要费劲转成.bin这个问题问得好。Qlib 确实提供了qlib.data.dataset里的一些接口可以读 CSV但那条路径只适合做快速验证一旦数据量上来性能差距是数量级的。.bin格式是内存映射友好的Qlib 的表达式引擎在计算因子时可以直接对内存映射文件做切片不需要把整个数据集加载进内存。对于 ETF 这种几十到几百个标的、每个标的上千条记录的规模.bin的优势可能还不明显但如果你后面要扩展到全市场股票这个选择就是必须的。另外.bin格式强制你统一字段类型和缺失值表示这本身就是一种数据质量约束。CSV 里一个空字符串、一个None、一个NaN可能被解析成不同的东西而.bin里只有float32的NaN干净利落。2.3 ETF 数据相比股票数据的特殊之处ETF 和个股在数据层面有几个差异这些差异会直接影响你的导入逻辑。第一ETF 也有复权问题但复权因子的计算方式和个股不完全一样tushare 的fund_daily接口返回的是未复权价格你需要自己用fund_adj接口拿复权因子。第二ETF 的成交量单位是“手”还是“份”不同数据源不一样tushare 返回的是“手”而 Qlib 里$volume的语义是股数或份数这个换算关系要在导入前统一。第三ETF 的上市和退市日期比个股更分散instruments文件里的起止日期必须逐个标的核对不能一刀切。3. 用 tushare 拉取 ETF 数据的实操细节3.1 接口选型与权限确认tushare 里和 ETF 相关的接口主要有三个fund_basic拿标的列表fund_daily拿日线行情fund_adj拿复权因子。这三个接口的积分要求不同fund_daily一般需要 2000 积分以上fund_adj要求更高一些。你在动手之前先确认自己的账号权限不然写到一半发现接口调不通会很尴尬。调用方式上我习惯先把标的列表拉下来存成一个本地 CSV后面所有操作都基于这个列表来循环。这样做的好处是避免每次调试都重复请求接口也方便你手动检查哪些标的是否需要剔除。import tushare as ts import pandas as pd pro ts.pro_api(你的token) # 拉取所有ETF基础信息 fund_basic pro.fund_basic(marketE) fund_basic.to_csv(etf_basic.csv, indexFalse) # 看一下有多少只以及上市日期分布 print(fund_basic.shape) print(fund_basic[list_date].describe())这里有个细节fund_basic的market参数传E表示场内 ETF如果你想要场外基金参数不一样。我们做量化回测一般只用场内 ETF因为场外基金没有实时价格回测意义不大。3.2 批量拉取日线行情的节奏控制tushare 的接口有频率限制fund_daily单次最多返回 5000 条记录而且每分钟调用次数有限。如果你有 300 只 ETF每只拉 5 年数据那就是 300 次调用不做节奏控制很容易被限流。我的做法是分批拉取每拉一只就time.sleep(0.3)并且把结果按标的存成单独的 CSV。这样即使中途断了也不用从头再来。import time import os os.makedirs(raw_daily, exist_okTrue) for code in fund_basic[ts_code]: try: df pro.fund_daily(ts_codecode, start_date20180101, end_date20241231) if df.empty: continue df.to_csv(fraw_daily/{code}.csv, indexFalse) time.sleep(0.3) except Exception as e: print(f{code} failed: {e}) time.sleep(1)提示fund_daily返回的字段包括trade_date、open、high、low、close、vol、amount等。注意vol的单位是“手”1 手 100 份。如果你要在 Qlib 里用$volume做因子建议统一换算成“份”也就是乘以 100。3.3 复权因子的获取与对齐复权是 ETF 数据里最容易出错的地方。tushare 的fund_adj接口返回的是adj_factor这个因子是累积的也就是说某一天的复权价格 未复权价格 × 当日复权因子 / 最新复权因子。但 Qlib 里的$close默认是未复权价格它期望你额外提供一个factor字段来表示复权因子然后在表达式里用$close * $factor来得到复权价。所以你的导入逻辑里close.day.bin存未复权收盘价factor.day.bin存复权因子。这样 Qlib 在计算因子时你可以自由选择是否复权灵活性更高。adj pro.fund_adj(ts_codecode, start_date20180101, end_date20241231) adj adj[[trade_date, adj_factor]] adj.to_csv(fraw_adj/{code}.csv, indexFalse)复权因子和日线数据通过trade_date对齐注意两边日期格式要统一tushare 返回的是YYYYMMDD字符串Qlib 要的是YYYY-MM-DD这个转换在最后一步做。4. 数据清洗与格式转换的核心环节4.1 日期对齐与缺失值处理把原始 CSV 读进来之后第一件事是构建一个全局交易日历。这个日历应该是所有 ETF 交易日的并集而不是交集。为什么用并集因为不同 ETF 的上市时间不同如果你用交集早期上市的 ETF 数据会被截断。Qlib 的日历是全局的每个标的在非交易日或未上市期间用NaN填充即可。all_dates set() for f in os.listdir(raw_daily): df pd.read_csv(fraw_daily/{f}) all_dates.update(df[trade_date].tolist()) all_dates sorted(all_dates) calendar pd.to_datetime(all_dates, format%Y%m%d).strftime(%Y-%m-%d) with open(qlib_data/calendars/day.txt, w) as f: f.write(\n.join(calendar))缺失值处理上Qlib 期望的是NaN不是 0。这一点很重要因为 0 在因子计算里是一个有效值会污染结果。比如某只 ETF 某天停牌成交量应该是NaN而不是 0否则你算出来的换手率因子会失真。4.2 字段映射与单位统一tushare 的字段名和 Qlib 的字段名不是一一对应的你需要做一个映射表tushare 字段Qlib 字段处理方式openopen直接使用highhigh直接使用lowlow直接使用closeclose直接使用volvolume乘以 100手转份adj_factorfactor直接使用这个映射看起来简单但实际做的时候要注意tushare 返回的vol在某些标的上可能是NaN乘以 100 之后还是NaN这个没问题。但如果你的数据里有 0 成交量那可能是真的停牌也可能是数据缺失需要结合amount字段判断。4.3 二进制写入的字节序与类型陷阱写.bin文件是整个流程里最容易翻车的一步。Qlib 用的是numpy的tofile方法默认float32。如果你用float64写进去Qlib 读出来的数据会完全错乱因为字节数对不上。import numpy as np def write_bin(filepath, values): arr np.array(values, dtypenp.float32) arr.tofile(filepath)这里有个隐藏的坑np.array在转换None或空字符串时会报错所以你的输入列表里必须是float或np.nan。我建议在写入之前先做一次pd.to_numeric(errorscoerce)把所有无法转换的值变成NaN。另外字节序的问题在 x86 机器上一般不用管因为都是小端序。但如果你在 ARM 机器上跑或者跨平台迁移数据就要显式指定f4而不是f4。5. 完整导入流程与验证方法5.1 从零到一的可复现脚本结构把前面所有步骤串起来一个完整的导入脚本应该包含以下阶段拉取 ETF 列表确定要导入的标的集合批量拉取日线行情和复权因子存为原始 CSV构建全局交易日历写入calendars/day.txt生成instruments/etf.txt每个标的的起止日期从数据里自动推断对每个标的按日历对齐数据写入各个字段的.bin文件用 Qlib 的 API 做一次读取验证第 4 步的起止日期推断逻辑是取该标的日线数据里最早和最晚的trade_date转成YYYY-MM-DD格式。不要手动写死日期因为 ETF 的上市退市时间会变。with open(qlib_data/instruments/etf.txt, w) as f: for code in codes: df pd.read_csv(fraw_daily/{code}.csv) start pd.to_datetime(df[trade_date].min(), format%Y%m%d).strftime(%Y-%m-%d) end pd.to_datetime(df[trade_date].max(), format%Y%m%d).strftime(%Y-%m-%d) f.write(f{code}\t{start}\t{end}\n)5.2 用 Qlib 读取验证三步确认法数据写完不代表导入成功必须用 Qlib 自己读一遍。我习惯用三步确认法第一步确认日历能读出来且长度和你的day.txt行数一致。from qlib.data import D cal D.calendar(start_time2018-01-01, end_time2024-12-31, freqday) print(len(cal))第二步确认标的列表能读出来且数量和你写入的etf.txt行数一致。instruments D.instruments(marketetf) print(len(D.list_instruments(instrumentsinstruments, as_listTrue)))第三步随便挑一个标的读几个字段和原始 CSV 做交叉比对。df D.features([510300.SH], [$close, $volume], start_time2020-01-01, end_time2020-01-10) print(df.head())如果这三步都过了说明数据链路是通的。如果第三步读出来的值和原始 CSV 对不上大概率是.bin写入时的字段顺序或者日历对齐出了问题。5.3 性能优化批量写入与内存控制当标的数量到几百只的时候逐个读写 CSV 会变得很慢。我试过的一个优化是先把所有标的的数据读进一个大的 DataFrame按日期和标的做多级索引然后一次性写入。但这样做内存占用会很高300 只 ETF × 2000 天 × 6 个字段大概需要几百 MB 内存一般机器扛得住。另一个优化是并行写入。因为每个标的的.bin文件是独立的可以用multiprocessing并行处理。但要注意Qlib 的日历文件是全局的必须在并行之前就写好不能每个进程各写各的。from multiprocessing import Pool def process_one(code): # 读取、对齐、写入该标的的所有字段 pass with Pool(8) as p: p.map(process_one, codes)注意并行写入时每个进程只写自己标的的文件夹不要碰calendars和instruments否则会出现文件锁竞争。6. 踩坑记录与常见问题速查6.1 数据层面的典型问题问题一复权因子为 0 或缺失。有些 ETF 在上市初期没有复权因子数据tushare 返回空值。这时候不能把factor写成 0应该写成 1.0表示不复权。否则$close * $factor会变成 0因子计算全错。问题二成交量单位不统一。前面说了 tushare 的vol是手但有些数据源是份。如果你混用了不同来源的数据一定要在导入前统一。我的做法是在字段映射表里显式标注单位换算并且在写入前打印几个样本值做人工核对。问题三日历里有重复日期。用set去重之后要排序但排序前要确保所有日期都是同一种格式。我遇到过20180101和2018-01-01混在一起的情况排序结果完全乱掉。6.2 Qlib 读取时的报错与排查报错一ValueError: cannot reshape array。这通常是.bin文件里的元素个数和日历长度不一致。排查方法是用np.fromfile读出来看len(arr)是否等于日历行数。如果不等说明你在写入时跳过了某些日期或者多写了。报错二KeyError: xxx。这说明 Qlib 在features目录下找不到对应标的的文件夹。检查文件夹名是否和instruments里的代码完全一致包括大小写和后缀。报错三读出来的值全是NaN。可能是.bin文件写成了全NaN也可能是日历对不上导致 Qlib 在错误的位置读取。先用np.fromfile直接读文件确认里面有有效值再检查日历。6.3 独家避坑技巧汇总问题现象可能原因排查方法解决方案读取值全为 NaNbin 文件写入全 NaNnp.fromfile 直接读检查源数据是否有值值与原始 CSV 不符字段顺序错位对比字段映射表重新按固定顺序写入日历长度不匹配日期格式不统一打印日历前 10 行统一转为 YYYY-MM-DD标的读不出来instruments 格式错误检查制表符分隔用 \t 而非空格复权价格异常factor 为 0检查 fund_adj 返回缺失时填 1.0我自己的习惯是每次导入完数据先跑一个最小的因子计算比如Ref($close, 1) / $close - 1看看收益率分布是否合理。如果出现极端值或者大量 NaN说明数据有问题。这个检查比任何单元测试都直接有效。7. 后续扩展从 ETF 到多资产类别这套流程跑通之后你会发现它不局限于 ETF。把fund_daily换成daily把fund_adj换成adj_factor就能导入个股数据。把fund_basic的market参数换掉就能导入不同市场的标的。核心逻辑是一样的拉取原始数据、对齐日历、统一字段、写入二进制。唯一需要额外考虑的是当你同时导入股票和 ETF 时instruments文件要分开写比如etf.txt和stock.txt然后在 Qlib 里通过market参数切换。不要混在一个文件里否则回测时会把两类资产混在一起导致换手率和持仓分布的计算出现偏差。另外如果你要做 ETF 轮动策略可能还需要导入指数数据作为基准。指数数据的接口和 ETF 不同但导入流程完全一致。你可以把指数当成一个特殊的“标的”代码用指数代码字段用相同的 OHLCV 结构。最后分享一个小技巧在features目录下你可以额外写入一些自定义字段比如pe、pb等估值指标只要保证每个字段一个.bin文件且长度和日历一致。Qlib 的表达式引擎会自动识别这些字段你可以在因子表达式里直接引用$pe。这比在策略代码里手动 merge 要优雅得多。