ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

QLIB数据源改造:用tushare搭建A股行情数据管线的完整实践

QLIB数据源改造:用tushare搭建A股行情数据管线的完整实践 做多因子模型和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.txttab分隔的股票列表文件每行四列代码、开始日期、结束日期、类型。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(exchangeSSE, start_date20200101, end_date20241231) 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_statusL, fieldsts_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_statusD拉退市列表将delist_date填到第三列。3. 核心实现tushare行情转QLIB格式3.1 接口选型与限流策略tushare拉日线行情有两种常见方式一种是pro.daily(trade_date20240603)按交易日拉全市场数据一次返回当天所有股票另一种是ts.pro_bar(ts_code600000.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, retries5, sleep3, **kwargs): for i in range(retries): try: return func(**kwargs) except Exception as e: print(f第{i1}次请求失败: {e}) time.sleep(sleep * (i 1)) raise RuntimeError(f请求失败: {kwargs})3.2 字段映射、单位换算与缺失值处理tushare的daily接口返回的字段和QLIB需要的字段基本能对应上但有几个必须手工处理。核心映射表如下tushare字段QLIB字段说明openopen开盘价单位元highhigh最高价lowlow最低价closeclose收盘价volvolumetushare单位是“手”QLIB通常按“股”存需要乘100adj_factorfactor复权因子具体换算见下一节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_datetrade_date) adj pro.adj_factor(trade_datetrade_date) df df.merge(adj[[ts_code, adj_factor]], onts_code, howleft) 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, dtypenp.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, dtypenp.float32).reshape(n_days, -1) new_arr np.insert(arr, stock_idx, col_values, axis1) 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(exchangeSSE, start_datedate_str, end_datedate_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_datetrade_date) adj pro.adj_factor(trade_datetrade_date) df df.merge(adj[[ts_code, adj_factor]], onts_code, howleft) 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, dtypenp.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服务器上用的是crontab30 16 * * 1-5 cd /path/to/project python daily_update.py logs/update.log 21脚本开头写好日志结尾打印本次更新的股票数量、字段数量、日历天数logging.info(f更新完成: {trade_date}, 股票数{len(df)}, 日历天数{len(cal_dates)})再写一个简单的校验更新后随机抽几只股票对比tushare原始数据确认close和factor写入无误def validate(ts_code, trade_date): raw pro.daily(ts_codets_code, start_datetrade_date, end_datetrade_date) adj pro.adj_factor(ts_codets_code, start_datetrade_date, end_datetrade_date) expected_close raw.iloc[0][close] # 从qlib的close.bin里读出来对比 close_arr np.fromfile(features/close.bin, dtypenp.float32) # ... 按symbol索引定位 assert abs(actual_close - expected_close) 1e-6, 数据不一致别小看这个校验它帮我抓过几次“日期序列错位”和“除权因子没刷新”的bug。真正跑生产环境宁可多花10秒校验也别让脏数据悄悄进库。5. 常见问题与排障实录5.1 常见问题速查表现象可能原因解决方案QLIB加载后股票数为0instruments格式错误或代码前缀不对检查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上建议先从小范围历史数据做起把格式、日历、因子这几个关键点验证清楚再放量跑全量。数据管线这件事慢就是快稳定压倒一切。
返回列表