做多因子模型和AI选股的这几年,QLIB算是我用得最顺手的研究框架,但它的数据源一直是个老大难。QLIB默认的Yahoo数据源在国内不是访问慢就是数据缺,尤其做A股策略时,经常遇到股票数量不全、复权价格对不上这类莫名其妙的问题。后来我把数据源切到了tushare,自己写了每日行情更新脚本,把A股全市场的日线数据、复权因子全部落到QLIB本地库里,从此训练因子、回测都顺手多了。这篇就把整套流程和踩过的坑完整写出来,给正在折腾QLIB数据的朋友一个能直接抄作业的参考。
QLIB本身是一套完整的AI量化投资平台,从数据清洗、因子计算到模型训练、回测都有对应的模块。但它的数据层设计得比较“固执”——所有研究流程都基于本地bin格式的features文件,不是随便给个DataFrame就能跑。这意味着你一旦决定用QLIB,就必须先把数据整理成它认识的样子。而tushare恰好能把A股行情数据完整地拉出来,两者结合其实非常自然,只是中间需要一层“翻译”和“搬运”。
1. 方案设计:让QLIB用上“国产化”数据流水线
1.1 QLIB的数据消费机制,决定了必须先建本地库
QLIB的数据组织方式很有特点,它不像普通数据库那样按表存,而是按“日期”和“股票代码”构成一个矩阵,每个字段拆成独立的bin文件。比如close.bin就是一个二维矩阵,行是交易日,列是股票,矩阵元素就是某只股票某天的收盘价。这种设计在因子计算时效率极高,所有行情数据一次性载入内存,配合它自研的expression engine,可以秒级算出几百个特征。
但也正是因为这个机制,QLIB对数据的完整性和一致性要求很高。日历文件里有多少天,bin矩阵就得有多少行;instruments文件里有多少只股票,bin矩阵就得有多少列。哪怕错一位,后续因子计算的结果就是错乱的,而且很难排查。所以第一步不是急着写爬虫,而是先把QLIB的数据目录结构和它要求的格式彻底搞清楚。
1.2 为什么放弃官方数据脚本,改用tushare自建更新
QLIB官方确实提供了get_data.py之类的脚本,能自动下载数据并dump成bin格式。但我实际用了之后发现几个问题。首先是数据源不稳定,官方脚本默认拉取的数据在国内网络环境下经常半路断掉。其次是覆盖范围不理想,A股股票经常缺胳膊少腿,停牌、新股、ST之类的标记也很含糊。最麻烦的是复权数据,官方数据里的factor字段跟国内行情软件对不上,导致算出来的收益率曲线跟真实交易有明显偏差。
tushare的优势在于它是国内专门做金融数据的接口,A股日线、复权因子、交易日历、上市状态这些都是标准化输出,数据粒度细、历史完整。更重要的是它有专门的adj_factor复权因子接口,配合pro.daily可以精确复原出任意一只股票的后复权价格。对于QLIB这种需要“原始价格 + 复权因子”组合输入的框架来说,tushare的数据结构几乎是为它量身定做的。
1.3 整体数据流设计
我的整体方案分成两条线:一条是全量初始化,负责把历史行情一次性灌入QLIB;另一条是每日增量更新,负责每天收盘后把当天数据追加进去。两条线共用同一套字段映射和格式转换逻辑,这样能保证增量不会和全量“打架”。
数据流大概是这样的:tushare接口拉到的原始DataFrame,经过代码格式转换、字段重命名、复权因子处理、单位换算之后,先落成标准CSV作为中间层,再通过QLIB的dump工具或者自定义bin追加脚本写进features目录。日历和股票列表也同步更新。这套流程听起来不复杂,但细节非常多,尤其复权因子的处理方式直接影响模型训练结果,后面重点讲。
2. 数据目录结构与初始化准备
2.1 QLIB本地数据目录长什么样
QLIB初始化的目录一般是~/.qlib/qlib_data/cn_data,如果你用过官方dump脚本,应该对这个结构不陌生。核心目录包括:
calendars/day.txt:每行一个交易日,格式是YYYY-MM-DD,这里决定了bin矩阵的行索引。instruments/all.txt:tab分隔的股票列表文件,每行四列:代码、开始日期、结束日期、类型。features/:每个字段一个bin文件,比如open.bin、high.bin、low.bin、close.bin、volume.bin、factor.bin,全部是float32的矩阵数据。instruments/:除了all.txt,可能还有按板块分的文件,但最核心的就是all.txt。
这个结构决定了后续所有操作。比如新增一天行情,本质上就是给每个bin文件追加一行;哪天有新股上市,就得给所有bin文件增加一列。相比普通数据库的“插入一行”,这种矩阵式存储对增量更新更“敏感”。
2.2 交易日历的初始化
日历是整个数据矩阵的“骨架”。用tushare初始化日历十分简单:
import tushare as ts pro = ts.pro_api('你的token') cal = pro.trade_cal(exchange='SSE', start_date='20200101', end_date='20241231') cal = cal[cal['is_open'] == 1]['cal_date'].sort_values() with open('day.txt', 'w') as f: for date in cal: f.write(f'{date[:4]}-{date[4:6]}-{date[6:8]}\n')这里有个细节值得注意:QLIB的日历通常比行情数据多一天。因为表达式引擎里大量使用Ref等未来函数,需要“未来一天”的索引来对齐数据。官方数据包里,日历末尾一般会比最后行情日期再多出一个工作日。我实践中通常会把最后一个交易日之后的下一个自然日或者下个工作日补进去,但不要多太多,否则会影响样本切分。
另外一个坑是,tushare的trade_cal默认返回的cal_date是YYYYMMDD格式字符串,写入day.txt必须改成YYYY-MM-DD,而且每一行干干净净,不能有多余空格。顺手写个简单校验:
# 校验文件合法性 lines = open('day.txt').read().split() assert all(len(x) == 10 for x in lines), '日期格式不对'2.3 股票列表(instruments)的初始化
instruments/all.txt的格式是tab分隔,每行表示一只股票的上市区间。QLIB用它来判断“某只股票在某段时间是否存在”。标准格式为:
SH600000 2000-01-01 2099-12-31 0前两列是代码和开始日期,第三列是结束日期,第四列0表示股票。这里最关键的坑是代码格式。tushare返回的股票代码是600000.SH这种带点的格式,QLIB要的是SH600000这种前缀在前的格式,转换函数必须写对:
def ts_code_to_qlib(code: str) -> str: symbol, market = code.split('.') if market == 'SH': return 'SH' + symbol elif market == 'SZ': return 'SZ' + symbol elif market == 'BJ': return 'BJ' + symbol else: raise ValueError(f'未知市场: {market}')上市日期可以从stock_basic里取:
basic = pro.stock_basic(exchange='', list_status='L', fields='ts_code,symbol,name,area,industry,list_date') basic['qlib_code'] = basic['ts_code'].map(ts_code_to_qlib)对于未退市的股票,结束日期我用2099-12-31,因为很多股票没有明确的退市时间,QLIB默认用end_date判断数据是否有效,给个足够远的未来日期最省事。已经退市的股票,可以用list_status='D'拉退市列表,将delist_date填到第三列。
3. 核心实现:tushare行情转QLIB格式
3.1 接口选型与限流策略
tushare拉日线行情有两种常见方式:一种是pro.daily(trade_date='20240603')按交易日拉全市场数据,一次返回当天所有股票;另一种是ts.pro_bar(ts_code='600000.SH', start_date=..., end_date=...)按股票拉历史区间。做全量初始化时,我强烈建议用pro.daily按日期拉取,因为A股现在5000多只股票,按股票遍历要发5000多次请求,而按日期遍历一年只有250个交易日,效率完全不在一个量级。
但按日期拉取有个限制:tushare对daily接口有积分门槛和每分钟调用次数限制。一般2000积分用户每分钟可以调500次,拉5年历史数据也就是1200多次调用,分3分钟就能跑完,完全够用。如果你积分不够,只能用pro_bar逐只股票拉,那就得做好断点续传和重试机制,不然很容易拉一半被限流。
我封装了一个带重试和限速的拉取函数,实际运行中很稳:
import time def fetch_with_retry(func, retries=5, sleep=3, **kwargs): for i in range(retries): try: return func(**kwargs) except Exception as e: print(f'第{i+1}次请求失败: {e}') time.sleep(sleep * (i + 1)) raise RuntimeError(f'请求失败: {kwargs}')3.2 字段映射、单位换算与缺失值处理
tushare的daily接口返回的字段和QLIB需要的字段基本能对应上,但有几个必须手工处理。核心映射表如下:
| tushare字段 | QLIB字段 | 说明 |
|---|---|---|
| open | open | 开盘价,单位元 |
| high | high | 最高价 |
| low | low | 最低价 |
| close | close | 收盘价 |
| vol | volume | tushare单位是“手”,QLIB通常按“股”存,需要乘100 |
| adj_factor | factor | 复权因子,具体换算见下一节 |
| pre_close | 不需要 | 前收盘,QLIB可以用Ref计算 |
| pct_chg | 不需要 | 涨跌幅,QLIB可以算 |
这里最容易出错的是volume的单位。QLIB官方存储的数据里,volume一般是“股”为单位,而tushare返回的是“手”。如果直接灌进去,所有成交量相关的因子都会偏小100倍,模型训练出来对流动性的判断完全是错的。我是在转换时统一乘100,并且在全量初始化时就用同一套逻辑,避免历史数据和增量数据口径不一致。
缺失值也要提前想清楚。A股停牌股票在tushare的daily里直接不返回记录,不会返回NaN。这意味着按日期拉完数据后,某些股票在当天是缺失的。QLIB的bin矩阵是稠密的,必须给这些位置填值。我的做法是填充np.nan,因为QLIB的计算引擎对NaN有统一处理,后续因子计算时也能通过Ref等函数正确跳过。千万不要自作聪明用0填充,否则会把停牌当成真实跌停,因子计算结果彻底乱掉。
3.3 复权因子的写入,可能是全文最重要的一个环节
QLIB的因子计算逻辑里,factor字段负责把原始价格“还原”成复权价格。QLIB官网数据包里factor字段是小于1的小数,表达式里一般写成$close / $factor得到复权价。tushare的adj_factor接口返回的是复权因子,数值通常在0.1到10之间,含义是“后复权价 = 原始价 × adj_factor / 基准日adj_factor”。严格来说,两者定义并不完全一致,所以不能直接拿tushare的adj_factor塞进QLIB的factor字段。
我的处理方式很简单:既然QLIB表达式用的是$close / $factor,那让$close / $factor恰好等于tushare的后复权价就行。即:
factor = 1.0 / adj_factor这样$close / $factor = $close * adj_factor,就是后复权价格。如果你自己的因子表达式里用的是$close * $factor,那直接把adj_factor存进factor字段也完全可以。关键是搞清楚你QLIB表达式里factor的运算符号,保持“因子表达式结果 = 真实复权价”这个等式成立。这个点我当时研究了好久才想明白,网上很多文章都含糊带过,导致不少人用错。
全量初始化时,每天合并行情和因子:
df = pro.daily(trade_date=trade_date) adj = pro.adj_factor(trade_date=trade_date) df = df.merge(adj[['ts_code', 'adj_factor']], on='ts_code', how='left') df['factor'] = 1.0 / df['adj_factor']增量更新时,每天重复同样的合并逻辑。要特别注意的是,adj_factor不是一成不变的——如果某只股票当天除权除息,它的历史复权因子理论上都会变化(这取决于tushare的复权算法基准)。实际操作中我发现tushare的adj_factor是根据最新股本变化实时重算的,所以每天拉到的历史因子可能和昨天不一样。这也是为什么增量更新不能只追加当天数据,最好定期做一次因子字段的全量刷新。这也是QLIB官方数据包和自建数据之间的一个常见差异。
3.4 用dump_bin还是自定义bin追加
数据转换完成后,写入QLIB有两条路。第一条是QLIB官方提供的dump_bin工具,适合全量初始化:
python qlib/scripts/dump_bin.py dump_all \ --csv_path /path/to/csv \ --qlib_dir /path/to/qlib_data \ --include_fields open,high,low,close,volume,factor \ --symbol_field_name symbol \ --date_field_name date前提是你先把行情整理成每个字段一列、每行是“symbol + date + 字段值”的长表CSV。dump_bin会帮你生成日历、股票列表和所有bin文件。这个方式胜在稳定,不用自己操作二进制,但每次全量重跑要几分钟,适合初始化或者每周做一次全量修复。
第二条路是直接操作bin文件做追加,适合每日增量。bin文件本质是float32数组,读出来reshape成“日期×股票”矩阵,再追加一天数据:
import numpy as np def append_feature(file_path: str, new_row: np.ndarray, n_days: int, n_stocks: int): arr = np.fromfile(file_path, dtype=np.float32).reshape(n_days, n_stocks) new_arr = np.vstack([arr, new_row.reshape(1, -1)]) new_arr.astype(np.float32).tofile(file_path)这里new_row按instruments里股票的固定顺序排列,顺序必须和初始化时一致。如果当天有新股上市,instruments里多了一只股票,那么所有bin文件都得“插一列”,这比追加一行麻烦得多。我的方案是:每日增量只追加日期维度,遇到新股上市时,周末跑一次全量重dump来吸收新列。这样既快又不容易出错。
新增股票时,bin矩阵需要插入新列:
def insert_stock_column(file_path: str, col_values: np.ndarray, stock_idx: int, n_days: int): arr = np.fromfile(file_path, dtype=np.float32).reshape(n_days, -1) new_arr = np.insert(arr, stock_idx, col_values, axis=1) new_arr.astype(np.float32).tofile(file_path)这个操作会重写整个文件,几百只股票的历史数据可能要几十秒,但偶尔跑一次可以接受。
4. 每日增量更新:从“跑全量”到“只更当天”
4.1 增量更新的整体思路
每日更新的流程比全量简单得多,但逻辑要更严谨。核心是三步:判断今天是不是交易日、拉当天数据转换格式、追加到日历和bin文件。任何一步出错,都会留下一个“坏数据日”,后面跑模型时各种诡异异常就来了。
交易日判断必须用tushare的trade_cal,不要自己瞎猜节假日。国家法定节假日调休安排年年变,AI也没法预测。示例逻辑:
def is_trade_date(date_str: str) -> bool: cal = pro.trade_cal(exchange='SSE', start_date=date_str, end_date=date_str) return cal.iloc[0]['is_open'] == 1有一点要注意,历史数据的截止日期不要用datetime.now(),要用“最近一个已收盘的交易日”。因为盘中拉数据时当天K线还在变,直接入库会污染历史序列。我是固定在每天16:30之后跑定时任务,此时当天日线和复权因子已经完整更新了。
4.2 增量更新脚本的核心逻辑
增量脚本的核心部分,我直接给出可运行的简化版本:
from pathlib import Path import pandas as pd import numpy as np import datetime as dt import tushare as ts ts.set_token('你的token') pro = ts.pro_api() QLIB_DATA_DIR = Path.home() / '.qlib/qlib_data/cn_data' FIELDS = ['open', 'high', 'low', 'close', 'volume', 'factor'] def ts_code_to_qlib(code): symbol, market = code.split('.') return {'SH': 'SH', 'SZ': 'SZ', 'BJ': 'BJ'}[market] + symbol def daily_update(trade_date: str): # 1. 判断日历是否已有该交易日 cal_file = QLIB_DATA_DIR / 'calendars/day.txt' cal_dates = cal_file.read_text().split() cal_idx = None if trade_date not in cal_dates: # 追加到日历,新日期放在末尾 with cal_file.open('a') as f: f.write(f'{trade_date[:4]}-{trade_date[4:6]}-{trade_date[6:8]}\n') cal_idx = len(cal_dates) else: cal_idx = cal_dates.index(trade_date) # 2. 拉行情和复权因子 df = pro.daily(trade_date=trade_date) adj = pro.adj_factor(trade_date=trade_date) df = df.merge(adj[['ts_code', 'adj_factor']], on='ts_code', how='left') df['factor'] = 1.0 / df['adj_factor'] df['volume'] = df['vol'] * 100 df['symbol'] = df['ts_code'].map(ts_code_to_qlib) # 3. 按instruments顺序组织新行并追加 all_instruments = QLIB_DATA_DIR / 'instruments/all.txt' symbols = [line.split('\t')[0] for line in all_instruments.read_text().splitlines()] new_row_dict = {row['symbol']: row for _, row in df.iterrows()} n_stocks = len(symbols) for field in FIELDS: new_row = np.full(n_stocks, np.nan, dtype=np.float32) for i, sym in enumerate(symbols): if sym in new_row_dict: val = new_row_dict[sym].get(field, np.nan) new_row[i] = val if pd.notna(val) else np.nan file_path = QLIB_DATA_DIR / 'features' / f'{field}.bin' append_feature(str(file_path), new_row, cal_idx, n_stocks)这里有个小坑:daily返回的open/high/low/close是字符串还是浮点?新版本tushare返回的已经是数值类型,但老版本偶发字符串,建议在转换时统一pd.to_numeric一把,防止类型错乱导致bin文件写入失败。
append_feature函数里,我传入了cal_idx,这是为了兼容“补历史某一天”的场景。如果今天的数据晚到了一天,需要插入到日历中间而不是末尾,那就不能用简单的vstack,得用np.insert按行插入。这部分逻辑虽然不常用,但一旦遇到数据回补,能省很多事。
4.3 定时任务与日志告警
每日更新最重要的不是代码有多优雅,而是“千万别静默失败”。我见过太多人定时任务挂了三天还在接着跑,结果数据缺了三天,因子计算错得一塌糊涂。所以脚本里必须加日志和校验。
我在Linux服务器上用的是crontab:
30 16 * * 1-5 cd /path/to/project && python daily_update.py >> logs/update.log 2>&1脚本开头写好日志,结尾打印本次更新的股票数量、字段数量、日历天数:
logging.info(f'更新完成: {trade_date}, 股票数={len(df)}, 日历天数={len(cal_dates)}')再写一个简单的校验,更新后随机抽几只股票,对比tushare原始数据,确认close和factor写入无误:
def validate(ts_code, trade_date): raw = pro.daily(ts_code=ts_code, start_date=trade_date, end_date=trade_date) adj = pro.adj_factor(ts_code=ts_code, start_date=trade_date, end_date=trade_date) expected_close = raw.iloc[0]['close'] # 从qlib的close.bin里读出来对比 close_arr = np.fromfile('features/close.bin', dtype=np.float32) # ... 按symbol索引定位 assert abs(actual_close - expected_close) < 1e-6, '数据不一致'别小看这个校验,它帮我抓过几次“日期序列错位”和“除权因子没刷新”的bug。真正跑生产环境,宁可多花10秒校验,也别让脏数据悄悄进库。
5. 常见问题与排障实录
5.1 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| QLIB加载后股票数为0 | instruments格式错误,或代码前缀不对 | 检查all.txt,必须是SH600000格式,tab分隔,四列齐全 |
| 因子计算结果全是NaN | 复权因子factor没写,或停牌日填了0 | 确认factor字段已写入,缺失值用NaN而不是0 |
| 收益率序列整体偏移一天 | 日历末尾没有预留“未来一天” | 在日历最后补一个最近工作日或当天 |
| 增量更新后bin文件错位 | 新日期append到了错误索引 | 严格根据calendar中的索引位置插入,不要一味堆在末尾 |
| tushare接口提示无权限 | 积分不够 | 提升积分,或改用pro_bar按单只股票拉取 |
| 更新到一半中断 | 网络问题或限流 | 加断点续传,记录最后成功日期,下次从中断处继续 |
| Volume因子数值异常 | 单位没换算 | 检查是否乘了100,与全量数据口径一致 |
5.2 几个容易忽略的细节
第一个细节是除权除息日的数据一致性。当天有新除权的股票时,tushare的daily返回的是除权后的价格,adj_factor也在当天发生变化。如果你只追加当天数据,却忘了同步更新这只股票历史的factor字段,那么用$close/$factor算出来的历史复权价在除权日前后会出现跳变。我的经验是,每天增量更新后,顺手对当天有adj_factor变化的股票做一次历史factor重算。判断方法很简单:对比昨天的adj_factor全量快照和今天的,因子变化的股票就是需要刷新历史的股票。
第二个细节是ST和退市整理期股票。很多AI选股模型会不小心把ST股学进去,导致策略实盘时选出一堆根本不能买的票。我一般会在初始化instruments时,把ST状态过滤掉,或者单独建一个instruments/st.txt做风险标记。tushare没有直接给ST标记,需要用daily_basic里name字段判断,或者用stock_basic拿行业分类时一起处理。这一步看起来很基础,但对策略效果影响很大。
第三个细节是bin文件末尾不能有多余字节。append的时候一定要以追加的矩阵的总字节数为准,不要残留旧文件的尾数据。我早期犯过一个错:np.ndarray.tofile()是直接覆盖写,如果新数据比旧数据短,文件末尾会残留旧字节,QLIB读取时直接解析错误。所以每次写完都要确认文件大小等于n_days × n_stocks × 4字节,这个校验成本极低,强烈建议加上。
第四个细节是内存问题。A股全市场5000多只股票、10年历史、几十个字段,bin矩阵全量载入内存也就几个GB,普通开发机其实扛得住,但如果你在云服务器上只有2G内存,那dump全量时可能会内存溢出。我的应对方式是按字段分批dump,跑完一个字段释放一次内存,或者用--include_fields只生成模型必需的字段,不要一股脑把换手率、量比这些全塞进去,后面需要再加。
5.3 用一次真实事故说明“因子刷新”有多重要
有一阵子我的模型训练结果突然变差,回测收益率曲线看着总是不对劲。排查了很久,最后发现是某只股票在6月中旬有一次大比例送转,10送10,价格直接砍半。由于我的增量更新脚本只追加了当天的close和factor,没有刷新历史factor,导致这只股票在6月中旬之前的复权价全部翻了倍。模型不知道这个事,把“价格突变”当成了真实收益,专门去学这种假信号。
那之后我把“因子刷新”做成了每日更新流程里的强制步骤:每天增量后,扫描所有股票的adj_factor相对于昨日快照的变动,凡是变了的股票,就重算它从上市以来的factor序列,并重写bin中对应的列。这样虽然每天多花几十秒,但数据一致性有了保障。A股分红送股频繁,每年除权除息的公司几百家,这个问题绝对不是小概率事件。
另外再提一句,tushare积分权限不同,能调用的接口差异很大。daily和adj_factor属于基础接口,积分要求不高,但如果你要拉分钟线、资金流向这类数据,就得更高积分。做日线模型用基础积分就够了,没必要一上来就充高等级的会员,先把日线数据管线跑顺再说。
QLIB和tushare这套组合我已经稳定跑了一年多,中间迭代过三次脚本结构,从最初的全量重dump到现在的增量+定期重建,数据质量和更新效率都有了质的提升。如果你也打算把自己的行情数据源接到QLIB上,建议先从小范围历史数据做起,把格式、日历、因子这几个关键点验证清楚,再放量跑全量。数据管线这件事,慢就是快,稳定压倒一切。