金穗农机指南

实战AI量化交易(7)-数据本地化服务框架

admin 136

在《实战AI量化交易》第一章里,我们介绍了通过Pytdx,Tushare和Joinquant来获取行情数据的方法。使用这些数据源,你可以很快上手编写策略。

但是一旦转入实战,你就会遇到种种障碍:回测中需要大量的数据,如果这些数据都从远程实时获取的话,会严重降低回测的速度;其次,一些数据源可能对你可用的数据量进行限制,这将导致回测中断甚至作废。

很显然,必须有一个高速的本地数据源,才能稳定地支撑我们的量化研究和交易。这个本地数据源缓存从远程得到的一切数据;如果我们需要最新数据,它也能自动去上游服务器获取。这样一来,无论是进行回测,还是实时交易就都能得到有力的支撑。

这一章,我们以大富翁(Zillionare)的子项目Omega为例,来介绍如何构建这样一个本地化的行情数据服务,并对相关技术栈的选择进行一些讨论。

提供数据本地化的开源项目不只Omega,比如QuantAxis,在github上获得了4kstars。但Omega的定位是,满足AI量化交易对数据存储的高速响应的需求。

Omega的定位是要对几乎任何数据请求都能提供秒级以下的响应,比如取全市场1个月的分钟线数据,这样加上计算时间,能够对分钟级别的信号应付裕如。但纳秒级的高频交易被排除在外。这种级别的高频交易,其策略都是套利模型,拼的主要是系统接入速度和性能,并不需要AI量化能力。而整个Zillionare的定位,是AI量化交易框架。

数据存储问题

与其它框架不同,Omega把行情数据全部存入了Redis缓存,除此之外,没有其它落地的方式。

这样做的好处是显而易见的。在追求高速的路上,我们选择了最简单粗暴的方式。但第一个问题就是,这得需要多大的内存?

根据测试,将A股的全部股票(目前约4000支,我们在测试中模拟了5000支)的4年的日线数据存入Redis,也只需要750MB左右的内存空间。如果精心设计存储结构和启用压缩,数据可能还有30%左右的压缩空间。

当然,如果你要存储4年的分钟线的话,这将需要近180G的存储空间;如果要存储Tick级的数据则会更多。但是对AI量化交易而言,跨度为4年的分钟数据并不比跨度1个月的分钟数据包含更多的信息–至少从K线图上来看,它们的pattern都是自重复的。所以我怀疑存储1个月以上的分钟数据这种需求是否真的存在。

眼见为实。下面,我们来进行一个测试,看看存储4年的A股全市场日线数据,需要多长时间和多大内存:

测试结果显示,假设我们以hashmap的方式来存储5000支个股4年左右的日线数据,只需要花大约580MB的内存空间;而存储这么多数据,也只需要240秒,也就是4分钟左右的时间。测试机器使用了SurfacePro4(2016年产),在wsl内安装的redis。如果是台式机或者服务器,这个时间将会更快。

与这里的测试代码中,我们给每根k线多存了一个时间帧字段,所以同样的数据量,大概需要750MB的内存空间。在时间上,这个方案的主要时间开销花在对K线数据的串行化上(),理论上可以使用orjson来进一步加速。

大富翁使用的是异步多进程读写方案,所以实际上读写这么多数据,花的时间还要比4分钟短很多,取决于你使用多少个进程。

这个方案与其它竞品相比,在时间和内存占用上都显示出较大的优势。如果需要Tick级的数据,需要使用列存储格式加上内存映射文件,再通过专门的数据服务器来提供。这是一个企业级的需求。

使用Redis来存放数据并非只有好处。上述存储方案也导致无法直接进行"今天收盘价小于3元的个股"这样的筛选。在AI量化交易中,这样的查询执行次数并不会频繁。我们写几个serverscript就好了。

财务数据的查询频率会比行情数据低很多,但需要支持更复杂的查询,因此我们将其存放在数据库中。大富翁选择的数据库是Postgres,它的性能是开源产品中首屈一指的。

数据的内存表示模型

当行情数据加载到Python进程时,其它框架一般选择使用PandasDataFrame。这对有速度要求的量化工程来说是个灾难。

大富翁选择Numpy数组作为几乎所有数据的内存表示模型。Numpy数组比DataFrame占用内存更小,在AI量化领域常用的计算领域,Numpy的计算性能要快10到数十倍,如果需要使用第三方库进行技术分析,这些第三方库多数也要求使用Numpy数组来传递参数(比如ta-lib)。我们来看下面的例子:

上面的测试结果显示,在slicing这个操作上,Numpy要快近30倍,在均值、最大值这些数值计算上,Numpy要快近10倍。

从内存上看,似乎Pandas只多用了128字节,但实际上并不是。Pandas在构建DataFrame时,往往需要多拷贝一两次Numpy数组。这种内存占用,从Pandas的角度来看,它已经释放了,但垃圾回收被推迟进行,在操作系统层面仍然能看到内存占用。这种占用,会在物理内存紧张时引起大量的页交换,从而显著降低程序性能。

此外,在量化交易领域,Numpy比PandasDataFrame还有一些不易察觉的易用性优势。比如,DataFrame不支持下面的使用方法:

但设想df是行情数据,我们常常会需要取行情数据的最后几条记录。这在Numpy表示中很方便;但在DataFrame表示中,你需要先把最后数据的索引算出来,然后才能取这个数据。

当然,DataFrame的替代都并不仅仅只有Numpy,Vaex和xarray,甚至dask对于Python程序员来说,都是常见的选择。在大富翁里我们选择了Numpy,因为我们只需要Numpy来暂存少量数据(几k到几十兆),进行一些简单而高效的计算和数据交换。从这一点来说,前面提到的这些工具都是牛刀杀鸡了,反而会产生各种适配问题。

异步多进程分布式

使用Redis和Numpy当然不能解决一个高性能的数据服务框架需要解决的全部问题。作为提供者,数据服务器必须具有高并发、低延迟响应的能力;同样,如果它不具有高并发向外请求(请求上游行情服务器)的能力,也就无法快速提供实时数据。

Omega采用了异步、多进程和分布式方案来解决满足高并发、低延迟响应的需求。下面的图展示了它的部署视图:

Omgea在行情数据获取上采用的是多进程分布式架构。Omega并不限制你使用某种特定的数据源,相反,只要有adaptor支持,任何上游数据源都可以使用。Omega的框架允许你使用多个异种数据源、或者同一数据源多个session并发。这样一来,限制获取实时行情速度的,就完全取决于你使用了多少session,或者上游服务器的能力如何了。

从上图可以看出,数据服务器(Omega)由一组Sanic服务器构成,通过前置的Nginx向数据消费者(比如策略、管理界面)提供数据。Sanic服务器是目前Python领域内最快的WebFramework之一(今年被Vibora夺走第一)。但从人气上看,目前仍胜Vibora一筹。

当Omega通过Sanic返回消费者请求的数据时,使用二进制协议,返回通过pickle串行化的二进制数据。尽管Sanic足够快,但HTTP作为一种面向应用层的协议,仍然比基于TCP的rpc调用要慢。在企业版,我们将提供基于rpc的方案。

由于每次读写Redis的数据量可能少到几十个字节,也可能多达上兆字节(比如,取全市场一年的日线),因此我们使用了aioredis来读写缓存,以避免某次大数据量的读写阻塞其它事务的执行。

同样地,在读写Postgres数据库时,我们也使用了异步库asyncpg,以及在之上提供异步ORM的gino。在高性能应用中使用ORM显然是不明智的,注意gino的ORM并不是传统的ORM,它只是在SQLAlchemycore(即提供SQL语句构建功能)之上的一个支持异步的封装。当发布之后,我们就可以直接在asyncpg中使用SQLAlchemycore,那时候可能也不再需要gino。

在Omega中,分布式主要体现在行情数据同步上。如果设定了对全市场所有证券的行情数据进行同步,假设共有5000支,那么Omega会将它分解成5000个子任务,放入一个在Redis中暂存的队列,并且通知工作者进程开始同步。这些工作者可以分布在不同的机器上。我曾经希望能有一个框架来完成任务分解和分布式执行。但是celery并不支持异步;Dask对纯计算进程友好,对涉及IO的进程似乎并不容易编程(或者只是我还不熟悉而已)。所以最终采取了这种自酿的方式。

此外,消费者进程通过Omicron向Omega请求数据时,这里并不是分布式计算,而是负载均衡技术。负载均衡通过Nginx来配置。对初学者或者不追求高性能的应用来说,这个配置可以省略。

整个Omega的技术栈如下:

关于Zillionare和Omega

Zillionare是由一系列子项目组成AI量化框架。Omega是其中的数据服务部分。项目目前托管在Github上。