数据方面主要包含以下操作:
数据部分的主要工作流如下:
Qlib里自带了一套直接下载股票数据并保存到本地的方法,且可以设置每日自动更新,这套数据我直接将其称为Qlib数据。
Qlib数据中包含了交易日历、A股全市场股票日级别量价数据(官网里说也支持分钟级),以及CSI100、300、500的股票池成分股数据。
这套数据对于我们了解Qlib框架大有帮助,建议使用如下代码先下载到本地。数据源是微软开放的URL地址,但是对于专业人士来说肯定不会只满足于此,一般都希望更换为自己熟悉的、稳定的数据源。
import qlib
# region in [REG_CN, REG_US]
from qlib.constant import REG_CN
provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir
qlib.init(provider_uri=provider_uri, region=REG_CN)
# 下载数据
from qlib.tests.data import GetData
aa = GetData()
aa.qlib_data(target_dir = provider_uri, region="cn")
这个下载方法和官网里给的方法相同,只不过官网给出的是命令行代码:
# daily data
python get_data.py qlib_data --target_dir ~/.qlib1/qlib_data/cn_data --region cn
# 1min data (Optional for running non-high-frequency strategies)
python get_data.py qlib_data --target_dir ~/.qlib1/qlib_data/cn_data_1min --region cn --interval 1min
对于学生或初学者而言,可以直接使用此的数据,并跳过本章接下来的内容。
下面我们将深入了解数据准备的高级内容,探索如何导入CSV数据以及更换自定义数据源。
一般而言,我们可以直接使用CSV数据进行深度学习与模型的开发。但Qlib声称其专门设计了一种数据结构(bin文件)来管理金融数据,这套数据的好处的更容易对数据的列进行科学计算,从而便于构建基于量价数据的金融指标。
Qlib提供了将CSV数据转为bin格式数据的脚本:scripts/dump_bin.py
我们先下载用于演示的CSV数据:
import qlib
# region in [REG_CN, REG_US]
from qlib.constant import REG_CN
provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir
qlib.init(provider_uri=provider_uri, region=REG_CN)
# 下载数据
from qlib.tests.data import GetData
aa = GetData()
aa.csv_data_cn(target_dir=provider_uri)
在文件夹中,我们可以看到每只股票的CSV数据格式,包含date、symbol,以及其他的特征列,如open、close等。
在scripts/dump_bin.py
文件的主函数中,我们运行如下代码:
provider_uri = "~/.qlib/qlib_data/cn_data" # target_dir
aa = DumpDataAll(csv_path=provider_uri, qlib_dir=provider_uri, include_fields='open,close,high,low,volume,adjclose')
# 执行转储
aa.dump()
即可对对应文件夹内的股票csv数据进行转储,该运行方式与官网中给出的脚本方式作用相同,即:
python scripts/dump_bin.py dump_all --csv_path ~/.qlib/csv_data/my_data --qlib_dir ~/.qlib/qlib_data/my_data --include_fields open,close,high,low,volume,factor
这里,我们细看一下aa.dump()
函数,其包含了三个子函数,分别用来存储日历、股票、特征数据:
def dump(self):
self._get_all_date()
self._dump_calendars()
self._dump_instruments()
self._dump_features()
先看self._get_all_date()
,我们先把该项目中常用到的多进程进度条更新代码框架给出:
# tqdm进度条,总进度数为总csv文件数
with tqdm(total=len(self.csv_files)) as p_bar:
# 多进程执行函数,self.works默认为16
with ProcessPoolExecutor(max_workers=self.works) as executor:
# 将csv_files中的文件名作为参数集,输入_dump_func中,并行运算。
for _ in executor.map(_dump_func, self.csv_files):
# 并行计算中的程序每完成一次,进度条更新一次
p_bar.update()
此时,我们再细看self._get_all_date()
的内容:
def _get_all_date(self):
logger.info("start get all date......")
all_datetime = set()
date_range_list = []
# 函数,用于获取每个股票csv中的日期列表
_fun = partial(self._get_date, as_set=True, is_begin_end=True)
with tqdm(total=len(self.csv_files)) as p_bar:
with ProcessPoolExecutor(max_workers=self.works) as executor:
for file_path, ((_begin_time, _end_time), _set_calendars) in zip(
self.csv_files, executor.map(_fun, self.csv_files)
):
# 将原all_datetime中的日期列表 与 新csv中的日期列表_set_calendars 合并,获得总日期列表 all_datetime_set
all_datetime = all_datetime | _set_calendars
# date_range_list 用于记录股票池,每行数据类似: SZ300800 2019-11-06 2020-09-23,
# 第一列为股票名,第二列为选入股票池时间,第三列为离开股票池时间(若未离开股票池,则为最新时间点)。
if isinstance(_begin_time, pd.Timestamp) and isinstance(_end_time, pd.Timestamp):
_begin_time = self._format_datetime(_begin_time)
_end_time = self._format_datetime(_end_time)
symbol = self.get_symbol_from_file(file_path)
_inst_fields = [symbol.upper(), _begin_time, _end_time]
date_range_list.append(f"{self.INSTRUMENTS_SEP.join(_inst_fields)}")
p_bar.update()
self._kwargs["all_datetime_set"] = all_datetime
self._kwargs["date_range_list"] = date_range_list
logger.info("end of get all date.\n")
接下来的_dump_calendars()
和_dump_instruments()
很简单,分别将日期列表和股票池列表存为txt。
重点说一下_dump_features()
,其同样嵌套了上面介绍的多进程进度条更新框架,单个csv文件的处理函数_dump_bin
如下:
# 输入参数为文件名和总日期列表,文件名支持path和pd.DataFrame两种格式,我们目前只看path
def _dump_bin(self, file_or_data: [Path, pd.DataFrame], calendar_list: List[pd.Timestamp]):
if not calendar_list:
logger.warning("calendar_list is empty")
return
if isinstance(file_or_data, pd.DataFrame):
if file_or_data.empty:
return
code = fname_to_code(str(file_or_data.iloc[0][self.symbol_field_name]).lower())
df = file_or_data
# 直接看这一步
elif isinstance(file_or_data, Path):
# 解析股票名
code = self.get_symbol_from_file(file_or_data)
# 获取csv文件中的数据,函数中将日期列表换为了统一的日期格式
df = self._get_source_data(file_or_data)
else:
raise ValueError(f"not support {type(file_or_data)}")
if df is None or df.empty:
logger.warning(f"{code} data is None or empty")
return
# 删除日期列中的重复行,一般不会存在重复,除非数据源数据不干净
df = df.drop_duplicates(self.date_field_name)
# 制作feature文件夹中,每个股票所对应的空文件夹,如:../feature/sz300800
features_dir = self._features_dir.joinpath(code_to_fname(code).lower())
features_dir.mkdir(parents=True, exist_ok=True)
# 继续下面的函数
self._data_to_bin(df, calendar_list, features_dir)
# 将股票数据存入对应的文件夹中。
def _data_to_bin(self, df: pd.DataFrame, calendar_list: List[pd.Timestamp], features_dir: Path):
if df.empty:
logger.warning(f"{features_dir.name} data is None or empty")
return
if not calendar_list:
logger.warning("calendar_list is empty")
return
# 对其索引,将df的日期索引更换为 calendar_list
_df = self.data_merge_calendar(df, calendar_list)
# 数据文件开头的日期索引,若为0,则表示从calendar_list中的第一天开始。
date_index = self.get_datetime_index(_df, calendar_list)
# 下面的代码比较好理解,通过nu.tofile()将数据分列存为bin格式,每个特征列中的数据存为一个bin文件。
for field in self.get_dump_fields(_df.columns):
bin_path = features_dir.joinpath(f"{field.lower()}.{self.freq}{self.DUMP_FILE_SUFFIX}")
if field not in _df.columns:
continue
if bin_path.exists() and self._mode == self.UPDATE_MODE:
# 更新模式
with bin_path.open("ab") as fp:
np.array(_df[field]).astype(").tofile(fp)
else:
# 首次存储
np.hstack([date_index, _df[field]]).astype(").tofile(str(bin_path.resolve()))
到此位置,大家对csv数据的存储基本上有了概念。其实说起来也很简单,就是分特征列,每列数据通过nu.tofile()存为一个bin文件。并没有什么过于高大上的技巧,只是方便列式计算因子、以及新增每只股票的特征并更新数据。
除此以外,Qlib还设计了一套用于处理财务数据的数据库——PIT Database (Point-In-Time Database) 。在财务数据(尤其是财务报告)中,同一条数据可能会被多次修改。如果我们只使用最新版本数据进行历史回测,就会发生数据泄露。PIT数据库旨在解决此问题,以确保用户在任何历史时间戳都获得正确版本的数据。它将保持实盘交易和历史回测的性能相同。截至2022.7,这套数据存储方法应该还处于开发中,不算特别成熟。过段时间,我会再对此进行讲解。
数据API主要用于快速对数据进行查询与检索,以及构建自定义特征。
我们先试用数据检索方法,首先初始化:
import qlib
qlib.init(provider_uri='~/.qlib/qlib_data/cn_data')
检索交易日历:
from qlib.data import D
aa = D.calendar(start_time='2010-01-01', end_time='2017-12-31', freq='day')[:2]
"""
[Timestamp('2010-01-04 00:00:00') Timestamp('2010-01-05 00:00:00')
Timestamp('2010-01-06 00:00:00') Timestamp('2010-01-07 00:00:00')
Timestamp('2010-01-08 00:00:00')]
"""
在给定日期内,获得股票池股票:
from qlib.data import D
instruments = D.instruments(market='csi300')
aa = D.list_instruments(instruments=instruments, start_time='2010-01-01', end_time='2017-12-31', as_list=True)[:5]
print(aa)
"""
['SH600000', 'SH600004', 'SH600009', 'SH600010', 'SH600011']
"""
使用名称过滤器 NameDFilter
:
from qlib.data import D
from qlib.data.filter import NameDFilter
nameDFilter = NameDFilter(name_rule_re='SH[0-9]{4}55')
instruments = D.instruments(market='csi300', filter_pipe=[nameDFilter])
aa = D.list_instruments(instruments=instruments, start_time='2015-01-01', end_time='2016-02-15', as_list=True)
print(aa)
"""
['SH600655', 'SH601555']
"""
使用表达式过滤器ExpressionDFilter
(注意,这里如有报错”RuntimeError: An attempt has been made to start a new process before the current process has finished its bootstrapping phase…“,需将函数运行在if __name__ == "__main__":
下面,如下所示:
import qlib
# region in [REG_CN, REG_US]
from qlib.constant import REG_CN
from qlib.data import D
from qlib.data.filter import ExpressionDFilter
if __name__ == "__main__":
provider_uri = "F:/qlib_data/cn_data" # target_dir
qlib.init(provider_uri=provider_uri, region=REG_CN)
expressionDFilter = ExpressionDFilter(rule_expression='$close>200')
instruments = D.instruments(market='csi300', filter_pipe=[expressionDFilter])
aa = D.list_instruments(instruments=instruments, start_time='2015-01-01', end_time='2016-02-15', as_list=True)
print(aa)
"""
['SH600066', 'SH600177', 'SH600340', 'SH600570', 'SZ000651', 'SH600415']
"""
获取股票在一段时间内的某些特征:
from qlib.data import D
instruments = ['SH600000']
fields = ['$close', '$volume', 'Ref($close, 1)', 'Mean($close, 3)', '$high-$low']
aa = D.features(instruments, fields, start_time='2010-01-01', end_time='2017-12-31', freq='day').head()
print(aa)
"""
$close $volume ... Mean($close, 3) $high-$low
instrument datetime ...
SH600000 2010-01-04 4.260015 3.292462e+08 ... 4.323008 0.142738
2010-01-05 4.292182 5.727642e+08 ... 4.304245 0.158820
2010-01-06 4.207747 4.814119e+08 ... 4.253314 0.084436
2010-01-07 4.113258 4.239778e+08 ... 4.204396 0.148772
2010-01-08 4.159496 3.268403e+08 ... 4.160167 0.100523
... ... ... ... ... ...
SH600066 2017-12-25 218.081284 8.424011e+05 ... 218.804443 6.131180
2017-12-26 222.420258 7.908619e+05 ... 219.936356 5.942520
2017-12-27 226.287613 1.909335e+06 ... 222.263046 11.696396
2017-12-28 226.098969 1.504564e+06 ... 224.935608 6.791458
2017-12-29 227.042236 1.003559e+06 ... 226.476273 4.527634
[3794 rows x 5 columns]
"""
当自定义的特征比较复杂时,可以改用如下方法,方便输入:
from qlib.data.ops import *
f1 = Feature("high") / Feature("close")
f2 = Feature("open") / Feature("close")
f3 = f1 + f2
f4 = f3 * f3 / f3
aa = data = D.features(["sh600519"], [f4], start_time="20200101").head()
print(aa)
"""
Div(Mul(Add(Div($high,$close),Div($open,$close)),Add(Div($high,$close),Div($open,$close))),Add(Div($high,$close),Div($open,$close)))
instrument datetime
sh600519 2020-01-02 2.011558
2020-01-03 2.071280
2020-01-06 2.007217
2020-01-07 1.988525
2020-01-08 2.000000
"""
在详细了解其他API之前,我们先说一说这个init大概做了些什么事。init的存在是事先配置环境,同时也支持客户端与服务器模式,后者我们先不细究,本地数据可以直接使用默认的客户端模式。
在事先配置环境中,除了设置我们比较容易理解的provider_uri、region等参数外,还有一个重要的作用,就是对数据provider相关函数的注册,即C.register()
,函数注册功能使我们可以在配置文件中通过参数(如本地模式或服务器模式)自动调用我们所需要的数据供给函数。
Provider基类使用了Mixin技术,即ProviderBackendMixin
,实现了类似于端分离的数据供给方式,其主要作用是方便切换多个不同的数据后端,设置自定义的数据源。
Provider数据供给函数的基类包括:CalendarProvider
、InstrumentProvider
、FeatureProvider
、PITProvider
、ExpressionProvider
、DatasetProvider
本地数据模式的Provider继承自以上基类,如:LocalCalendarProvider
;
服务器数据模式的Provider继承自以上基类,如:ClientCalendarProvider
;
除上面以外,还有个更加方便、更上层的数据API——BaseProvider
,相当于集成用户对了CalendarProvider
、InstrumentProvider
、FeatureProvider
的数据需求,方便我们直接查看数据库中的日历列表、股票池、股票特征等。
此API同样有本地和客户端两个子类:LocalProvider
和ClientProvider
。我们实例中用到的from qlib.data import D
便是本地数据子类LocalProvider
。
这一节的数据API是便于用户查询和操作数据特征,下一节我们将介绍用于模型的数据加载器和数据处理器,即Data Loader与Data Handler。
刚开始,我对Data Loader和前一章中的Data API的异同存在一定疑惑,两者都是获取数据的工具,有什么使用上的区别呢?官方的文档中似乎也并没有对此做细致的说明。稍看一下源码我们便知晓,其实Data Loader仅仅只是前一章中数据查询API的封装,使我们更加方便的获取我们所需的数据而已。
因此,我的建议是,在工具的使用过程中,直接忘掉Data API获取数据的方法,统一改用Data Loader来获取数据(这是官网文档中比较迷惑的一部分,官网文档中详细介绍了Data API的使用,但却没有详细介绍Data Loader的使用,这会对我等新人造成一定的误导)。
from qlib.data.dataset.loader import QlibDataLoader
qdl = QlibDataLoader(config=(['$close / Ref($close, 10)'], ['RET10']))
qdl.load(instruments=['sh600519'], start_time='20190101', end_time='20191231')
"""
datetime instrument RET10
2019-01-02 sh600519 1.014326
2019-01-03 sh600519 0.998409
2019-01-04 sh600519 1.041883
2019-01-07 sh600519 1.053943
2019-01-08 sh600519 1.065878
... ... ...
2019-12-25 sh600519 0.978188
2019-12-26 sh600519 0.998329
2019-12-27 sh600519 1.000000
2019-12-30 sh600519 1.032999
2019-12-31 sh600519 1.011128
"""
Data Handler则是基于Data Loader,通过更高级的封装,使我们仅需要简洁的语言便可以对加载的数据进行处理,如数据标准化、填充nan值等。
首先,我们先查看未进行数据处理的数据:
df = qdl.load(instruments=['sh600519'], start_time='20190101', end_time='20191231')
df.isna().sum()
"""
RET10 4
dtype: int64
"""
df.plot(kind='hist')
接下来,我们进行数据处理:
from qlib.data.dataset.handler import DataHandlerLP
from qlib.data.dataset.processor import ZScoreNorm, Fillna
# NOTE: normally, the training & validation time range will be `fit_start_time` , `fit_end_time`
# however,all the components are decomposed, so the training & validation time range is unknown when preprocessing.
dh = DataHandlerLP(instruments=['sh600519'], start_time='20170101', end_time='20191231',
infer_processors=[ZScoreNorm(fit_start_time='20170101', fit_end_time='20181231'), Fillna()],
data_loader=qdl)
df = dh.fetch()
df.isna().sum()
"""
RET10 0
dtype: int64
"""
df.plot(kind='hist')
输出图片:
可以看到,经过处理后,数据中的nan数量变成了0,且数据分布更接近于正太分布。
将Data Handler处理后的数据,再用Dataset类处理,就成为了我们所熟悉的机器学习(深度学习)数据集格式,我们可以直接将此数据集导入模型开始训练。
这里,我们通过dataset将数据集分为训练集和预测集:
from qlib.data.dataset import DatasetH, TSDatasetH
ds = DatasetH(dh, segments={"train": ('20180101', '20181231'), "valid": ('20190101', '20191231')})
ds.prepare('train')
"""
datetime instrument RET10
2018-01-02 sh600519 0.745535
2018-01-03 sh600519 1.022683
2018-01-04 sh600519 1.075765
2018-01-05 sh600519 1.195880
2018-01-08 sh600519 1.514492
... ... ...
2018-12-24 sh600519 -0.529233
2018-12-25 sh600519 -0.764602
2018-12-26 sh600519 -1.105943
2018-12-27 sh600519 -1.372358
2018-12-28 sh600519 -0.337375
"""
ds.prepare('valid')
"""
2019-01-02 sh600519 -0.001186
2019-01-03 sh600519 -0.278425
2019-01-04 sh600519 0.478799
2019-01-07 sh600519 0.688852
2019-01-08 sh600519 0.896746
... ... ...
2019-12-25 sh600519 -0.630645
2019-12-26 sh600519 -0.279825
2019-12-27 sh600519 -0.250719
2019-12-30 sh600519 0.324053
2019-12-31 sh600519 -0.056887
"""