QLIB这套框架有个挺有意思的现象:文档和官方Example都极其完善,从下载数据到跑通LightGBM因子模型一路顺畅,但只要你把数据集从Yahoo Finance换成国内A股,立刻就能感受到什么叫“折腾才刚刚开始”。最大的分水岭就在数据上——QLIB对输入数据的格式要求非常严格,而tushare返回的数据又完全是另一套风格。这篇文章就是把我自己从“tushare拉数据”到“QLIB能正常跑因子回测”的完整链路记录下来,重点解决每天收盘后怎么增量更新行情这件事,顺带把复权、交易日历、bin文件重新dump这些坑都填平。
1. 为什么QLIB对行情数据如此“挑剔”
1.1 三张表对齐:QLIB数据体系的核心结构
QLIB不是一个只吃K线的回测框架,它把数据拆成了三个相互关联的存储对象:calendar、instrument、feature。你可以把它们想象成图书馆的三个柜台:calendar告诉你图书馆哪天开门(交易日),instrument告诉你书架上有哪些书(股票代码和上市退市区间),feature则记录每本书每天的内容变化(OHLCV等行情指标)。
calendar表本质就是一个纯日期列表,存的是所有交易日,格式像2024-01-02、2024-01-03这样。instrument表则记录了每只股票的代码、市场板块、上市日期、退市日期、行业分类,QLIB依赖它来判断某只股票在某一天是否“存在”。feature表是核心,每只股票对应一个独立文件,里面存着该股票按交易日排列的行情和因子字段。
这个三表结构意味着:你从tushare拉下来的数据不能直接塞给QLIB,必须先转换成围绕这三张表的格式。很多新手把tushare返回的DataFrame直接存成CSV,然后用D.instruments()去读,结果发现股票列表为空、因子跑出来全是NaN——就是因为instrument表的信息没有同步维护。
1.2 QLIB对文件名和字段名的“强迫症”
如果你打开QLIB官方提供的示例数据目录,会发现文件名长这样:
$SH600000.csv $SH600036.csv $SZ000001.csv注意两个细节:文件名带$符号,且股票代码前面有市场前缀。SH代表上交所,SZ代表深交所,这是QLIB区分同代码股票(比如600000在SH和BJ如果重号的话)的关键约定。而feature文件的列名要求是标准格式:
| 字段 | 说明 |
|---|---|
| open | 开盘价 |
| high | 最高价 |
| low | 最低价 |
| close | 收盘价 |
| volume | 成交量 |
| factor | 复权因子 |
| vwap | 均价(可选) |
| turnover | 换手率(可选) |
QLIB自带的dump_bin.py脚本在读取CSV时,会严格检查索引列是否为DatetimeIndex,字段名是否在配置的include_fields里。如果你用tushare直接导出,列名是中文或trade_date、vol这类,dump过程就会报字段不匹配的错误。
1.3 复权价格:QLIB因子计算的前提
热词里专门有“tushare如何计算复权”,这确实是绕不开的坎。QLIB在计算技术因子(如MA、RSI、BOLL)时,输入的价格序列会被当作真实可交易价格来处理。如果因为除权除息导致价格出现断崖式跳空(比如10转10,股价从20元瞬间变10元),那么基于价格计算的因子第二天会出现巨大的假突变,模型会学到完全错误的信息。
所以送入QLIB的价格必须是复权价。QLIB核心开发者建议使用后复权价,原因是前复权会随时间推移不断修正历史价格,每次除息后所有历史数据都会变化,这会导致你昨天建好的因子库和今天重建的因子库整体不一致。后复权则锚定过去,历史价格不随新除权事件改变,天然具备稳定性,更适合做长时间的因子研究和回测。
拿tushare的复权因子表(adj_factor)来说,一只股票上市首日因子为1,之后每次除权除息因子发生变化。后复权价格的计算逻辑是:后复权收盘价 = 不复权收盘价 × 当日累计因子。tushare的adj_factor接口返回的本身就是日频的累计复权因子,直接用不复权价格乘上这个因子就是后复权价。而pro_bar(adj='hfq')则是tushare直接帮你算好的后复权价,省去了手动乘的步骤。
注意:QLIB官方只要求你给的价格是“合理可用的价格”,不强制必须是后复权。但如果你用前复权,每次除权后增量更新历史数据会被重写,导致不同批次dump出来的bin文件价格基线不一致,这在回测时非常容易埋雷。我强烈建议统一用后复权。
2. tushare取数前必须确认的三件事
2.1 token权限与接口门槛
tushare是社区常见的A股数据接口,但它的“积分制”经常让新人措手不及。daily基础行情接口需要一定积分,而pro_bar的复权参数、adj_factor复权因子、trade_cal交易日历都有各自的调用权限。具体门槛数值一直在调整,我建议以tushare官网文档为准,这是最权威的信息来源。
实际操作中,我遇到的情况是:积分不够时调用pro_bar(adj='hfq')会返回空DataFrame或直接抛异常,但接口文档又没写清楚。我的排查经验是,先单独调一次pro.daily(),能返数据再往上叠加adj参数。如果daily能返回而pro_bar无法返回,大概率就是积分权限问题。
2.2 pro_bar复权参数与复权因子接口的选择
tushare里取日线行情有两条路:daily和pro_bar。daily返回的是不复权数据;pro_bar是在daily基础上封装了一层,支持adj参数,可选qfq(前复权)和hfq(后复权)。
我的建议是直接用pro_bar(adj='hfq'),因为它在底层已经处理了停牌、上市首日、涨跌停等特殊情况,返回的字段也更规整。另外如果你需要自己存一份复权因子,可以单独调adj_factor接口,存进MySQL或直接合并到CSV的factor列。QLIB在dump_bin时会把factor字段也写进bin文件,后续算因子时会用到。
这里有一个容易犯的错:把adj_factor直接当成“因子”传给QLIB。adj_factor的确是复权因子,也是QLIB里factor列的首选数据,但对tushare返回的复权行情来说,pro_bar(adj='hfq')的close已经包含了复权调整,此时如果再把factor列填进去,会导致QLIB的某些复权相关操作产生叠加效应。稳妥的做法是:要么只用pro_bar的复权价,factor列填1;要么只用不复权价,factor列填adj_factor,然后让QLIB内部通过factor字段进行价格调整。我个人的习惯是后复权价 + factor列填1,简单直接,回测逻辑清晰。
2.3 交易日历:别用节假日猜
沪深交易所的交易日不是简单的“周一到周五剔除周末”,元旦、春节、国庆这种长假会导致某周的某一天不开市,偶尔还会有临时休市。tushare提供的trade_cal接口返回的is_open=1记录就是准确的交易日清单。
在增量更新脚本里,交易日历的作用很关键:你要决定“今天数据更新到哪一天”。如果当天是交易日但数据还没出来(盘后数据通常要等到下午5点到7点才稳定),脚本需要判断并跳过,等下一个周期再拉。如果用普通工作日判断,很可能遇到“今天周三是节假日休市,但程序傻乎乎去拉数据,拉回来一坨空值往CSV里写,把全量数据表污染了”的情况。
3. 全量初始化:把过去N年A股行情倒进QLIB
3.1 目录结构与配置文件
在动手跑全量之前,先建好目录。我习惯把数据根目录放在qlib_data下面,里面分csv和bin两层:
qlib_data/ ├── csv/ │ ├── calendars/ │ │ └── day.txt │ ├── instruments/ │ │ ├── all.txt │ │ └── stocks.txt │ └── features/ │ ├── $SH600000.csv │ ├── $SH600036.csv │ └── ... └── bin/ ├── calendars/ ├── instruments/ └── features/然后QLIB的配置入口指向bin目录即可。配置文件一般是~/.qlib/qlib_init.conf或者在代码里直接设置:
from qlib.config import REG_CN from qlib.constant import REG_CN import qlib qlib.init(provider_uri="qlib_data/bin", region=REG_CN)3.2 全量拉取脚本
我用的是tushare的pro接口,先拉股票列表,再逐只拉历史日线。下面这个脚本就是以2020年至今为区间做全量初始化的示例:
import tushare as ts import pandas as pd import os from datetime import datetime ts.set_token('你的token') pro = ts.pro_api() start_date = '20200101' end_date = datetime.now().strftime('%Y%m%d') os.makedirs('qlib_data/csv/features', exist_ok=True) # 1. 股票列表 stock_basic = pro.stock_basic(exchange='', list_status='L', fields='ts_code,symbol,name,area,industry,list_date') stock_list = stock_basic['ts_code'].tolist() # 2. 交易日历 cal = pro.trade_cal(exchange='SSE', start_date=start_date, end_date=end_date, is_open='1') trade_days = cal['cal_date'].tolist() # 3. 写入calendar with open('qlib_data/csv/calendars/day.txt', 'w') as f: for d in sorted(trade_days): f.write(d + '\n') # 4. 逐只拉行情并写CSV for ts_code in stock_list: symbol = ts_code.split('.')[0] exchange = ts_code.split('.')[1] if exchange == 'SH': symbol_with_prefix = 'SH' + symbol elif exchange == 'SZ': symbol_with_prefix = 'SZ' + symbol else: continue # 跳过北交所或扩展市场,按需处理 df = ts.pro_bar(ts_code=ts_code, adj='hfq', start_date=start_date, end_date=end_date, factors=['tor', 'vr']) if df is None or df.empty: continue df = df.rename(columns={'trade_date': 'date'}) df['date'] = pd.to_datetime(df['date']) df = df.sort_values('date') df = df.set_index('date') df = df[['open', 'high', 'low', 'close', 'vol', 'amount', 'factor']] df.columns = ['open', 'high', 'low', 'close', 'volume', 'amount', 'factor'] df.to_csv(f'qlib_data/csv/features/${symbol_with_prefix}.csv', index=True)这个脚本有几个容易出错的细节:
pro_bar在传adj='hfq'时,如果还传factors=['tor','vr'],返回的字段里会多出换手率和成交量比率,正好可以补全技术因子所需的数据,一步到位。- QLIB官方示例里字段名是
volume,不是vol,所以导入前要重命名。别小看这个字段名问题,dump_bin.py在include_fields里配置了volume,你给的CSV里却叫vol,它会直接跳过这个字段,最后bin文件里volume全为空。 - 我在全量拉取时把
amount也保留下来了,虽然QLIB默认因子不一定用到,但后续自定义因子(比如典型价格、动量)会需要成交额。
3.3 调用dump_bin.py生成bin文件
CSV准备完毕后,用QLIB官方的dump_bin.py将文本数据转成二进制格式:
python scripts/dump_bin.py dump_all --csv_path qlib_data/csv --qlib_dir qlib_data/bin --include_fields open,high,low,close,volume,amount,factor注意几点:
dump_bin.py是QLIB源码scripts目录下的脚本,直接从GitHub克隆QLIB仓库就能找到。--include_fields这个参数在较新的QLIB版本里才强制要求,老版本默认只转open、high、low、close、volume等核心字段。如果你用了自定义字段,务必在include_fields里显式列出来。- dump过程会同时生成
calendars/day.txt和instruments/all.txt,但instrument文件里的内容是从csv/instruments/all.txt读入的。所以如果你自己在csv/instruments/all.txt里写了股票列表,dump时它会一并转过去。如果没有这个文件,dump会报错找不到instrument定义。
3.4 验证初始化结果
转完bin后,可以用QLIB读取来验证:
import qlib from qlib.data import D from qlib.config import REG_CN qlib.init(provider_uri="qlib_data/bin", region=REG_CN) print(D.calendar(start_time='2024-01-01', end_time='2024-01-31')) print(D.instruments(market='csi300')) df = D.features(["SH600000"], ["$close", "$volume"], start_time='2024-01-01', end_time='2024-01-31') print(df.head())如果df能正常返回非空数据,说明bin文件没问题。如果返回全空或NaN,优先检查是不是calendar和feature的日期区间没对齐——比如股票2020年1月才上市,但你从2020年1月开始拉数据,而instrument表里该股票的start_time写成了2019年1月,QLIB在2020年1月这个点上会认为股票还没上市,数据一概不返回。
4. 每日增量更新:核心逻辑与完整脚本
4.1 增量更新的本质:不是“追加”,而是“重建”
先说一个容易误解的点:QLIB的bin文件不支持增量追加,dump_bin.py的工作原理是把CSV全量读进来再全量写入bin。所以“每日更新行情”这件事,实际步骤是:
- 用tushare拉取最新一天的行情(增量数据)。
- 将增量数据合并到已有的CSV文件里。
- 重新调用
dump_bin.py,让bin文件整体重建。
只要数据量停留在几千只股票、几年历史的规模,重建一次bin文件的耗时大概在1~3分钟左右,完全在可接受范围内。但如果你的数据系统已经积累了十几年的高频数据,那就要考虑分区整理或只dump近期区间。不过对于大多数个人量化研究,重建bin的策略最简单可靠。
4.2 增量更新脚本的组件拆解
我需要把这个脚本拆成四个核心函数,分别处理不同的逻辑:
第一步:获取最近交易日和待更新日期
def get_trade_days(pro, end_date): cal = pro.trade_cal(exchange='SSE', start_date='20240101', end_date=end_date, is_open='1') days = sorted(cal['cal_date'].tolist()) return days这里的关键逻辑是:从calendar文件里读出最后一个交易日,然后再到trade_cal返回的列表里找这个日期后面的所有交易日。只有这些“缺失的交易日”才需要去拉行情。
第二步:增量拉取指定区间的行情
def fetch_incremental(pro, ts_code, start_date, end_date): df = ts.pro_bar(ts_code=ts_code, adj='hfq', start_date=start_date, end_date=end_date, factors=['tor', 'vr']) if df is None or df.empty: return pd.DataFrame() df = df.rename(columns={'trade_date': 'date'}) df['date'] = pd.to_datetime(df['date']) df = df.sort_values('date').set_index('date') df = df[['open', 'high', 'low', 'close', 'vol', 'amount', 'factor']] df.columns = ['open', 'high', 'low', 'close', 'volume', 'amount', 'factor'] return df第三步:合并到本地全量CSV
这一步比想象中容易出问题。因为tushare的pro_bar返回的是整个区间的全部交易日数据,如果某一天数据因为晚到或缺数而没返回,那合并后就会产生“空洞”。所以合并时不能简单concat,而应该用combine_first或按日期做merge去重。
def merge_with_local(incremental_df, csv_path): if os.path.exists(csv_path): old_df = pd.read_csv(csv_path, index_col='date', parse_dates=True) else: old_df = pd.DataFrame() combined = pd.concat([old_df, incremental_df]) combined = combined[~combined.index.duplicated(keep='last')] combined = combined.sort_index() combined.to_csv(csv_path, index=True) return combined第四步:重新dump_bin
和全量初始化一样,增量更新后的最后一步也是调用dump_bin.py。
python scripts/dump_bin.py dump_all --csv_path qldb_data/csv --qldb_dir qldb_data/bin --include_fields open,high,low,close,volume,amount,factor如果你希望脚本自动执行而不手工敲命令,可以把这个调用放到subprocess里,或者直接用Python调用QLIB的dump函数。我个人更喜欢直接subprocess,因为它能复用现有环境变量和Python路径,少踩一些坑。
4.3 完整增量脚本参考
把上面几段拼起来,一个可落地的最小脚本大概长这样:
import os import subprocess import pandas as pd import tushare as ts from datetime import datetime ts.set_token('你的token') pro = ts.pro_api() CSV_FEATURES_DIR = 'qlib_data/csv/features' CALENDAR_FILE = 'qlib_data/csv/calendars/day.txt' CSV_ROOT = 'qlib_data/csv' BIN_ROOT = 'qlib_data/bin' # 1. 读取本地已有的最后一个交易日 with open(CALENDAR_FILE, 'r') as f: last_local_date = f.readlines()[-1].strip() today = datetime.now().strftime('%Y%m%d') # 2. 获取实际交易日 cal = pro.trade_cal(exchange='SSE', start_date=last_local_date, end_date=today, is_open='1') all_days = sorted(cal['cal_date'].tolist()) if last_local_date in all_days: all_days = all_days[all_days.index(last_local_date) + 1:] if not all_days: print('没有需要更新的新交易日') exit(0) incremental_start = all_days[0] incremental_end = all_days[-1] # 3. 获取股票列表(这里只更新全市场有交易的股票,可适当过滤) stock_basic = pro.stock_basic(list_status='L', fields='ts_code') ts_codes = stock_basic['ts_code'].tolist() failed_codes = [] # 4. 逐只拉增量数据并合并 for ts_code in ts_codes: symbol, exchange = ts_code.split('.') if exchange == 'SH': csv_name = f'${exchange}{symbol}.csv' elif exchange == 'SZ': csv_name = f'${exchange}{symbol}.csv' else: continue csv_path = os.path.join(CSV_FEATURES_DIR, csv_name) try: inc_df = fetch_incremental(pro, ts_code, incremental_start, incremental_end) if not inc_df.empty: merge_with_local(inc_df, csv_path) except Exception as e: failed_codes.append(ts_code) print(f'处理 {ts_code} 失败: {e}') # 5. 重新dump_bin cmd = [ 'python', 'scripts/dump_bin.py', 'dump_all', '--csv_path', CSV_ROOT, '--qlib_dir', BIN_ROOT, '--include_fields', 'open,high,low,close,volume,amount,factor' ] subprocess.run(cmd, check=True) print(f'增量更新完成,更新区间 {incremental_start} - {incremental_end}') if failed_codes: print(f'失败股票数: {len(failed_codes)}')这个脚本在运行时有个值得注意的点:pro.stock_basic(list_status='L')只返回当前上市状态的股票,如果某只股票在最近一个月发生暂停上市、重新上市、退市整理等情况,它就不会出现在列表里,那么这只股票的增量行情就漏了。我的处理方式是,每周末单独跑一次“全市场股票列表对比”,把新上市/退市的股票同步到instrument表里,并做一次更长期限的补偿拉取。
4.4 更新耗时估算
以全市场5000只股票、增量1个交易日计算,循环里每次pro_bar调用大概耗0.3秒左右,总耗时约25~30分钟。这个速度基本取决于tushare接口的延迟和你的积分等级,积分越高单次调用返回越快。如果嫌太慢,可以改成多线程并发拉取,但要小心tushare每分钟调用次数的限制,建议控制在每分钟60次以内,否则容易触发频率限制。
5. 部署上线:定时任务、边界情况与数据校验
5.1 用cron定时执行
增量脚本写好后,放到服务器上用crontab调度。A股收盘是15:00,盘后数据一般在17:30~20:00之间稳定,考虑到tushare的数据更新延迟,我建议把任务安排在每天21:00之后运行,比如:
0 21 * * 1-5 cd /path/to/project && /usr/bin/python incremental_update.py >> logs/update_$(date +\%Y\%m\%d).log 2>&1这里的重点是把标准输出和错误都重定向到日志文件,否则你很难发现在某一天因为一个接口异常导致后面的股票全部更新失败。日志里最好带上每只股票的处理状态和耗时,方便事后排查。
5.2 边界情况:新股、停牌、退市
增量更新最怕的其实不是“拉不到数据”,而是“只拉到了部分数据”且数据里混着异常值。
- 新股上市:新股在
stock_basic里会以list_status='L'存在,但一定存在一个“上市首日”之前的空白期。如果增量区间覆盖了上市首日,pro_bar会自动返回从上市首日开始的数据,合并时没问题。但如果新股上市首日是在上一个交易日之后而我们增量区间没覆盖到,它就会被漏掉。我的做法是每周一做一个全量stock_basic对比,把新出现的股票连带上市首日到上周五的历史数据一次性补全。 - 停牌:tushare的
pro_bar对停牌日通常会返回空,也就是说增量区间里某天停牌,那天的数据就是不存在的。合并CSV后,这个日期对应的行不会出现在文件里,这是正常的。QLIB的D.features在读取时遇到缺失交易日会用NaN填充或前向填充,取决于你的数据处理器配置。 - 退市/风险警示:退市股在
list_status='D'里,如果你只用list_status='L',退市股不会进入增量更新范围,但历史bin里已有的数据不会受影响。风险警示股票(ST、*ST)和正常股票一样更新,QLIB不会因为你给它喂ST股就报错,只是你构建训练集时要自行过滤。
5.3 数据完整性校验
增量更新后,bin文件是全新生成的,理论上只要CSV是完整的,bin就不会有问题。但CSV的完整性需要单独校验。我习惯在更新脚本最后加一段校验逻辑:
# 随机抽10只股票,检查最新日期是否为预期交易日 import random sample_codes = random.sample(ts_codes, 10) latest_expected = all_days[-1] for code in sample_codes: symbol, exchange = code.split('.') csv_path = os.path.join(CSV_FEATURES_DIR, f'${exchange}{symbol}.csv') df = pd.read_csv(csv_path, index_col='date', parse_dates=True) if str(df.index.max().date()) != latest_expected: print(f'{code} 最新日期为 {df.index.max().date()},期望 {latest_expected}')这段代码虽然简单,但在生产环境里非常有价值。它能在你还没跑回测前就发现“某只股票最新交易日缺失”的问题。否则等你用D.features拉数据训练模型时才发现某只股票缺了几天,排查成本会高很多。
5.4 磁盘占用与bin目录管理
bin文件是二进制格式,比CSV压缩了不少,但如果积累了多年全市场日线数据,磁盘占用还是会慢慢涨起来。我的经验值是:5000只股票 × 10年日线 × 7个字段,bin目录大约在3~5GB左右,CSV目录会更大。建议在服务器上每个月压缩一次历史CSV,或者在dump_bin之后直接删除成色较旧的CSV文件,只保留最近一年的CSV做增量合并参考。
不过有一点要留神:dump_bin.py在重新dump时依赖CSV目录里的全部数据,如果CSV删得太早,后续增量更新时merge_with_local读到旧数据会缺失,bin重建出来的历史也不完整。所以要么保留全部CSV,要么在删除前确认bin里已有数据是完整的,且不要再做全量重建。
6. 复权计算的细节与常见误区
6.1 tushare复权因子和手动计算的方法
tushare的adj_factor接口返回的是「复权因子」,它不是涨跌幅,而是一个连乘累积的系数。想要从原始不复权价格算后复权价,公式是:
后复权价 = 不复权价 × 当日复权因子举个例子,某股票上市首日收盘价10元,复权因子是1;发生一次10转10后,若除权日当天不复权收盘价为5元,复权因子会变成2,后复权价就是10元。这样价格序列就没有“跳空”了,技术指标的计算结果也更连贯。
pro_bar(adj='hfq')实际上就是底层做了这个乘法,但如果你需要自定义复权方式,比如想用“后复权价 + 真实成交量”的组合,就可以手动实现:
df_raw = pro.daily(ts_code='600000.SH', start_date='20240101', end_date='20240401') df_factor = pro.adj_factor(ts_code='600000.SH', start_date='20240101', end_date='20240401') df = pd.merge(df_raw, df_factor, on='trade_date', how='left') df['close_hfq'] = df['close'] * df['adj_factor']6.2 “前复权”在增量更新里的危害,比你想的更大
前面已经提过,前复权价会随着每次除权而修改所有历史价格。这在一次性全量导入时没有问题,但在增量更新场景下非常危险:
比如今天是4月1日,你用pro_bar(adj='qfq')拉了1月1日到4月1日的数据,存进QLIB。等到4月15日发生一次高送转,你增量拉4月2日到4月15日的数据,此时tushare返回的前复权价会把这个除权事件应用到整个历史区间,导致你CSV文件里1月1日到4月1日的那部分历史价格突然变化。但你的增量合并逻辑是按日期去重的,旧文件里的历史日期不会因为新数据而更新,于是CSV里出现了“前半段是旧前复权价、后半段是新前复权价”的割裂状态。bin重建后,因子计算就会用到这种前后矛盾的序列,结果混乱。
后复权则不存在这个问题,因为历史价格锚定过去,除权事件只会影响除权日及之后的价格,增量合并时旧历史数据完全不需要变。这就是为什么我在整个流程里坚持用adj='hfq'的根本原因。
6.3 除权日当天应该保留哪一条价格线
增量更新时,如果除权日恰好落在增量区间内,pro_bar返回的除权日价格已经是复权后的价格,直接写入CSV没问题。但如果用的是daily接口做手动复权,要特别注意除权日当天原始收盘价和复权因子的对应关系:除权日当天的adj_factor已经反映了除权,所以计算后复权价时直接用当日因子相乘即可,不需要额外“跳过”除权日。
另外,除权除息日当天开盘竞价往往有大幅波动,有些框架会建议在回测时剔除除权日当天或前一天的信号,以免因子突变诱发虚高绩效。但这是策略层面的处理,数据层只需保证复权价连续即可。
7. 运行过程中的踩坑记录与解决思路
这里记录几个我实际运行这套增量更新流程时遇到的问题,都属于“不踩一次真的想不出来”的坑。
7.1 dump_bin后读取bin,股票数量少了
我全量dump后,用D.instruments(market='all')一看,发现只有3000多只股票,而tushare的stock_basic里明明有5000多。排查了很久才发现是CSV文件里有些股票数据为空(比如上市交易日当天没有行情返回),导致dump_bin.py直接跳过了该文件。它在日志里会打印类似“skip empty feature $SZ000001.csv”的提示,只是很容易被忽略。解决方法是:在生成CSV时,遇到空DataFrame,手动创建一个只有索引、没有行数据的空表,或者写入一行NaN占位,确保文件不为空。更推荐的做法是全量脚本跑完后检查一遍csv/features目录下的文件数量,和stock_basic的数量比对,及时补拉。
7.2 新旧版本dump_bin参数不一致
QLIB的dump_bin.py在不同版本里对dump_all的参数要求有变化。老版本只要指定--csv_path和--qlib_dir,默认会把CSV目录下所有列都dump进去;新版本要求你用--include_fields显式列字段。如果你直接用网上的旧示例命令跑新版本源码,会看到类似“error: the following arguments are required: --include_fields”的报错。这个看报错就能解决,但如果你使用的是别人封装好的镜像或docker,容器里QLIB版本可能很老,字段参数又不一样。最稳妥的方式是直接python scripts/dump_bin.py dump_all --help,看当前版本的help输出,再按参数说明执行。
7.3 时区与日期格式的隐性坑
tushare返回的trade_date是字符串格式YYYYMMDD,QLIB的calendar文件里也是这种格式的日期字符串。但在合并进DataFrame时,我把date列转成了pd.to_datetime,这会自动加时区00:00:00。如果后续用D.features查询时传入的start/end也是字符串,QLIB会统一按当天零点处理,一般没问题。但如果你本机是UTC+8,而服务器是UTC,日期索引的datetime在读写CSV时可能会发生偏移,导致某天的数据被判断成前一天或后一天。为了避免这个问题,我在写CSV前就把时间统一设置为pd.to_datetime(df.index).tz_localize(None),去掉时区信息。
7.4 内存不足导致dump中断
全量dump时如果CSV文件特别大,且dump_bin.py默认读取所有CSV进内存,可能直接OOM。我的解决思路是分市场dump:先只dump上证,再只dump深证。具体做法是在csv/features目录下建子目录,每个子目录放一部分股票,然后用dump_bin.py分别执行,最后把calendars和instruments目录里的公共文件拷贝到最终bin目录。这个方法有点绕,但对个人服务器16G内存的环境来说,能省掉很多麻烦。
8. 我最后想说的几个建议
这套“QLIB + tushare 每日增量更新”的流程,我在个人量化环境里已经稳定跑了小半年。除了一开始那些格式问题之外,后续真正让我觉得值得继承的经验其实就三条。
第一,复权方式一定要在数据入口就锁死。不管是自己写合并逻辑,还是用别人封装好的导入工具,把adj='hfq'这类设定写死在配置里,并且每一次增量更新都校验一下最新价格和tushare官网的复权价是否一致,能避免非常多的隐性误差。
第二,CSV层与bin层分开看待。CSV是“源数据”,bin是“派生数据”。所有清洗、合并、去重都尽可能在CSV层完成,bin只作为QLIB运行时的高效读取格式。这样就算bin文件坏了,你也可以随时从CSV重建;反过来如果只改bin不改CSV,下次增量更新时你的修改会被覆盖掉。
第三,监控要简单但有效。我每天只看两个指标:更新日志里“失败股票数”是否为0,以及随机抽样的20只股票的最新交易日是否等于当天交易日。这两个指标能拦住绝大部分更新异常。如果你连随机抽样都觉得麻烦,那就在更新任务后加一行SQL:SELECT COUNT(*) FROM stock_feature WHERE date = 'latest_trade_date',只要数量在合理区间内,基本就说明更新成功了。
最后分享一个我习惯用的小技巧:每次增量更新完成后,用QLIB跑一个最简单的小因子(比如$close的MA5)做一次数据完整性自查。如果这个基础因子能正常算出来,说明从tushare到QLIB这条链路是通的,再往上叠加复杂因子时才不会怀疑是数据层的锅。这套流程本质上就是把“取数—落盘—转格式—刷新”四件事固定下来,后面无论策略怎么变,数据底座都不用再折腾了。