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(market='E') fund_basic.to_csv('etf_basic.csv', index=False) # 看一下有多少只,以及上市日期分布 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_ok=True) for code in fund_basic['ts_code']: try: df = pro.fund_daily(ts_code=code, start_date='20180101', end_date='20241231') if df.empty: continue df.to_csv(f'raw_daily/{code}.csv', index=False) 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_code=code, start_date='20180101', end_date='20241231') adj = adj[['trade_date', 'adj_factor']] adj.to_csv(f'raw_adj/{code}.csv', index=False)复权因子和日线数据通过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(f'raw_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 字段 | 处理方式 |
|---|---|---|
| open | open | 直接使用 |
| high | high | 直接使用 |
| low | low | 直接使用 |
| close | close | 直接使用 |
| vol | volume | 乘以 100,手转份 |
| adj_factor | factor | 直接使用 |
这个映射看起来简单,但实际做的时候要注意: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, dtype=np.float32) arr.tofile(filepath)这里有个隐藏的坑:np.array在转换None或空字符串时会报错,所以你的输入列表里必须是float或np.nan。我建议在写入之前先做一次pd.to_numeric(errors='coerce'),把所有无法转换的值变成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(f'raw_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_time='2018-01-01', end_time='2024-12-31', freq='day') print(len(cal))第二步,确认标的列表能读出来,且数量和你写入的etf.txt行数一致。
instruments = D.instruments(market='etf') print(len(D.list_instruments(instruments=instruments, as_list=True)))第三步,随便挑一个标的,读几个字段,和原始 CSV 做交叉比对。
df = D.features(['510300.SH'], ['$close', '$volume'], start_time='2020-01-01', end_time='2020-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 独家避坑技巧汇总
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读取值全为 NaN | bin 文件写入全 NaN | np.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 要优雅得多。