Qlib教程——基于源码(二)本地数据保存与加载

文章目录

  • 1. Data Preparation
    • 1.1 Qlib 数据
    • 1.2 CSV数据
  • 2. Data API
    • 2.1 数据检索示例
    • 2.2 关于 qlib.init()
  • 3. Data Loader
    • 3.1 数据查询示例
  • 4. Data Handler
  • 5. Dataset

本篇主要讲解Qlib源码中数据的获取与保存部分。
从上一章中我们知道,源码中与数据相关的,主要是以下两个文件夹:

  • scripts 脚本文件,用于外部数据下载与保存
  • data 数据缓存与处理相关文件

数据方面主要包含以下操作:

  • Data Preparation
  • Data API
  • Data Loader
  • Data Handler
  • Dataset
  • Cache
  • Data and Cache File Structure

数据部分的主要工作流如下:

  1. 用户下载CSV数据并将数据转换为 Qlib 格式(文件名后缀为.bin的二进制文件)。在此步骤中,通常只有一些基本的量价数据存储在磁盘上,如开盘价、收盘价、成交量等。除了量价数据以外,Qlib还设计了一套用于处理财务数据的PIT数据库。
  2. 基于Qlib的表达式引擎创建一些基本特征(例如“Ref($close, 60) / $close”,最近60个交易日的收益)。这一步通常在Qlib的Data Loader中实现,它是Data Handler的一个组件。
  3. 如果用户需要更复杂的数据处理(例如数据标准化),Data Handler支持用户自定义的处理函数来清洗数据。处理函数不同于表达式引擎中的运算符。它是为一些复杂的数据处理方法而设计的。
  4. 最后,Dataset 负责将 Data Handler 处理后数据存为数据集,用来输入模型。

1. Data Preparation

1.1 Qlib 数据

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数据以及更换自定义数据源。

1.2 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,这套数据存储方法应该还处于开发中,不算特别成熟。过段时间,我会再对此进行讲解。

2. Data API

2.1 数据检索示例

数据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                                                                                   
"""

2.2 关于 qlib.init()

在详细了解其他API之前,我们先说一说这个init大概做了些什么事。init的存在是事先配置环境,同时也支持客户端与服务器模式,后者我们先不细究,本地数据可以直接使用默认的客户端模式。

在事先配置环境中,除了设置我们比较容易理解的provider_uri、region等参数外,还有一个重要的作用,就是对数据provider相关函数的注册,即C.register(),函数注册功能使我们可以在配置文件中通过参数(如本地模式或服务器模式)自动调用我们所需要的数据供给函数。

Provider基类使用了Mixin技术,即ProviderBackendMixin,实现了类似于端分离的数据供给方式,其主要作用是方便切换多个不同的数据后端,设置自定义的数据源。

Provider数据供给函数的基类包括:CalendarProviderInstrumentProviderFeatureProviderPITProviderExpressionProviderDatasetProvider

本地数据模式的Provider继承自以上基类,如:LocalCalendarProvider

服务器数据模式的Provider继承自以上基类,如:ClientCalendarProvider

除上面以外,还有个更加方便、更上层的数据API——BaseProvider,相当于集成用户对了CalendarProviderInstrumentProviderFeatureProvider的数据需求,方便我们直接查看数据库中的日历列表、股票池、股票特征等。
此API同样有本地和客户端两个子类:LocalProviderClientProvider。我们实例中用到的from qlib.data import D便是本地数据子类LocalProvider

这一节的数据API是便于用户查询和操作数据特征,下一节我们将介绍用于模型的数据加载器和数据处理器,即Data Loader与Data Handler。

3. Data Loader

刚开始,我对Data Loader和前一章中的Data API的异同存在一定疑惑,两者都是获取数据的工具,有什么使用上的区别呢?官方的文档中似乎也并没有对此做细致的说明。稍看一下源码我们便知晓,其实Data Loader仅仅只是前一章中数据查询API的封装,使我们更加方便的获取我们所需的数据而已。

因此,我的建议是,在工具的使用过程中,直接忘掉Data API获取数据的方法,统一改用Data Loader来获取数据(这是官网文档中比较迷惑的一部分,官网文档中详细介绍了Data API的使用,但却没有详细介绍Data Loader的使用,这会对我等新人造成一定的误导)。

3.1 数据查询示例

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
"""

4. Data Handler

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')

输出图片:
Qlib教程——基于源码(二)本地数据保存与加载_第1张图片
数据中存在4个nan,且数据分布不均匀。

接下来,我们进行数据处理:

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')

输出图片:
Qlib教程——基于源码(二)本地数据保存与加载_第2张图片
可以看到,经过处理后,数据中的nan数量变成了0,且数据分布更接近于正太分布。

5. Dataset

将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
"""

你可能感兴趣的:(量化投资专栏,数据挖掘,深度学习,python,pytorch)