前言
《WonderTrader架构详解》系列文章,上周介绍了一下WonderTrader的整体架构。本文是该系列文章的第二篇,主要介绍WonderTrader数据处理的机制。
往期文章列表:
数据的分类
量化平台对于数据的依赖无需多言,量化平台除了作为数据的消费者以外,同时又是数据的生产者。对于一个量化平台,需要跟不同的数据打交道。
量化平台处理不同类型的数据的时候,为了达到更好的处理效率,会采用不同的处理方式。那么数据又怎么分类呢?笔者依照自己多年的经验,大概将数据分为以下几类:
1、从时间角度区分
从时间角度区分,我们可以把数据分为实时数据和历史数据。
实时数据,主要针对行情数据,包括实时的tick数据、分钟线数据,以及股票
level2
数据。实时数据对于策略的重要性自不必多说,信号触发、止盈止损、风险控制都离不开实时数据,这就要求实时数据的处理必须满足两个基本条件:快速和稳定。快速指的是处理速度要快,不能因为处理数据而增加太多的延时;稳定指的是数据不能丢失,要快速持久化。除此之外,实时数据还有一些别的特点:一般情况下数据量相对历史数据较少、数据访问频率高等等。
历史数据,也是主要针对行情数据,包括历史分钟线、历史高频数据如tick和股票level2数据。也包括基本面数据、异类数据等,因为这类数据更新频率一般较低,基本上也可以归为历史数据。
和实时数据不同,历史数据在一个交易日的范围内可以看作是静态数据。历史数据的特点是:数据量大、访问频率低、访问一般按照时间范围筛选数据。这样的特点,就要求历史数据在存储的时候必须要考虑检索的便利性、存储的成本、管理的便利性等问题。此外,还需要考虑一些特别的场景下的需求,诸如:是否要给投研人员直接查阅、是否要对外提供数据服务等。
2、从频率角度区分
从频率角度区分,我们可以把数据分为高频数据和低频数据。
高频数据,我们一般指的是以秒甚至毫秒为单位更新的数据,如前文提到的tick数据和股票的
level2
数据。高频数据一般数据量都非常大,以A股
level2
数据为例,以csv
方式存储的话,一天的数据量几十个G是没有问题的。这样大的数据量,如果放到关系型数据库中,那基本上就是灾难了。另外,实时的高频数据对延迟也非常敏感,如国内期货的tick
数据是500ms
一笔,如果处理时速度慢,下一个时刻的tick
进来的时候,还有很多数据没有处理完,就会造成数据延迟太大,缓冲队列长度不断增加,对于策略来说也是一种灾难。低频数据,我们一般指的是分钟线以上周期的K线数据、财务数据,以及其他更新时间间隔在1分钟以上的数据。
低频数据相对高频数据来说,数据量就小了很多。以A股数据为例,
tick
数据3秒一笔,1分钟线的数据量是tick
数据的1/20
,而5分钟线的数据量是tick
数据的1/100
。由于低频数据的数据量不大,我们在存储低频数据的时候,也有了更多的选择余地。关系数据库、文件系统、NoSQL
数据库等等,都可以根据不同的使用场景列入选择的范围。
3、从来源角度区分
从来源角度区分,我们可以把数据分为行情数据、交易数据和平台数据。
行情数据,我们处理数据的核心还是围绕行情数据展开的。
行情数据,主要指的就是从行情接口接入的实时行情数据,以及通过数据伺服对实时行情数据做再加工而得到的各种二次数据,策略的计算都只针对行情数据进行的。行情数据处理得好,可以节约读取的时间,提升策略的处理速度。行情数据再加工的数据质量,也是影响策略表现的一个因素。因为行情数据的特殊性,所以行情接口都只会推送最新的快照信息过来,这就要求如果需要使用更早的行情数据,就必须自己处理实时行情数据落地的工作,还得向使用模块提供数据访问的接口。
交易数据,主要指的是从交易接口获取到的交易相关的数据,诸如资金数据、持仓数据、订单数据等。
交易数据和行情数据不同,因为交易数据会在交易柜台维护一个完整的数据,所以我们可以通过交易接口拿到最新的交易数据,而不需要自己去做落地和维护。另一方面,考虑到多点登录等情况,我们也不能完全信任本地缓存的交易数据,而需要实时同步柜台的交易数据。这其实对于平台来说,是降低了维护的难度,只需要每次登录交易通道的时候做一次同步,后面就可以根据回报自动更新了。
平台数据,主要指的是平台作为数据的生产者生产的数据,如策略输出的信号、策略的理论部位和理论资金等数据。
平台生成的数据,是对策略的绩效进行分析来说非常重要的数据,这里面包括逐笔的成交数据、每一个进出场的完整的回合数据(平仓明细),以及每天收盘后的资金数据。这样的数据一般来说都不会太多,毕竟一般情况下,交易信号的数量是远小于行情数据的数量的。同理,这样的数据更新频率也不会太高,所以对于存储的要求也没有那么高。
数据的存储
前面大致介绍了一下对数据的分类。不同类的数据如何存储呢?如何兼顾读写的效率和便捷性呢?不同的使用场景又如何选择存储方式呢?数据的安全性又如何保障呢?
1、基本原则
WonderTrader在存储数据的时候,遵循以下基本原则:
效率优先
效率优先,主要指的是数据读写的效率要尽量高,一方面是实时数据的接收处理速度快,而策略在使用最新数据的时候也要尽可能的高效访问,另一方面是历史数据的读取速度快。
便于管理
便于管理,也分两个方面:一个方面是数据的可维护性要强,因为数据出错的情况总是难以避免的,而数据的存储,不能影响数据的可维护性。另一方面是要便于迁移,因为在部署新的策略执行节点的时候,总是需要一些历史数据的,如果不便于迁移,那么新部署策略执行节点就会非常麻烦。
2、存储方式对比
在WonderTrader迭代的过程中,笔者也曾经用过不少存储方式。大致上分为文件存储、关系数据库、分布式数据库三种方式。
文件存储
文件存储,最简单也最难,对开发也有一定要求。简单在于不依赖任何服务,随便什么语言都能很容易的对文件进行读写。难点在于,如何设计一个合理的文件数据格式,才能够满足业务场景的需要,让读写更高效,这对架构和开发的要求还是比较高的。文件读写的速度一般都很快,因为没有冗余,都是程序直接访问。一些处理速度要求高的场景,利用
mmap
把文件映射到内存中,处理速度会更快,而且还不会丢失数据(除非硬盘坏掉)。笔者刚开始入行的时候,是一家股票数据供应商,就是用的这种方式存储数据。而WonderTrader最终也是采用的文件存储的方式,虽然中间兜了一个大圈才绕回来。关系数据库
关系数据库,作为数据存储的传统主力,一直占用一席之地。关系数据库,如
MYSQL
、MSSQL
和Oracle
等,对于结构化的数据非常友好,虽然读写效率不如文件存储,因为要建立索引,还要引入额外的空间占用,但是对于大部分低频数据的存储还是能够满足的。数据库存储有一个最好的好处就是一般数据库都有可视化管理工具,非常方便对数据进行管理。如果要搭建一个投研平台,要方便团队内部成员查看数据的话,那么关系数据库会是一个不错的选择。WonderTrader的历史数据也支持MYSQL存储的方式。分布式数据库
分布式数据库是时下大数据浪潮下的宠儿。笔者之前在一家量化私募工作的时候,专门调研过当时比较主流的一些分布式数据库,包括
Hadoop
、Cassandra
、Mysql
集群等。笔者认为,分布式数据库存储的实现和关系型数据库没有太大的差别,而分布式数据库的核心在于数据的安全性(有备份)和事务处理的并发性。对于量化平台来说,数据存储不会有特别复杂的业务逻辑,所以不用担心分布式事务这方面的问题,核心关注的点还是在于查询数据可以在多个节点并发执行,从而提高效率。笔者认为,分布式数据库是比较适合较大的量化团队或者研究团队使用的,因为团队成员多,各类数据的总量也会非常巨大,分布式数据库能够轻松胜任这样对的应用场景。
此外,目前还流行一种NoSQL
数据库类型,这类数据库,可以是分布式的,也可以是独立的,相同点在于不使用SQL
语句,而是用别的接口进行访问。笔者曾经使用过leveldb
进行数据存储,读写速度能够满足绝大多数场景,这也是一种NoSQL
数据库。但是leveldb
的致命缺陷是独占式管理,也就是说没有办法基于leveldb
构建读写分离的机制。最后一次重构WonderTrader,笔者也彻底抛弃了leveldb
,又回到文件存储的思路。
3、WonderTrader存储机制
鉴于以上存储方式各自不同的特点,WonderTrader结合了部分需求,设计了自己的数据存储机制。
实时行情数据全部采用文件存储原始数据结构,并使用
mmap
的机制映射到内存中,便于读写typedef struct _BlockHeader { char _blk_flag[FLAG_SIZE]; //文件头的特殊编码,用于识别是否为自定义文件 uint16_t _type; //数据类型标记 uint16_t _version; //文件版本,不压缩为1,压缩存放为2 } BlockHeader; typedef struct _RTBlockHeader : BlockHeader { uint32_t _size; //数据条数 uint32_t _capacity; //数据容量 uint32_t _date; //交易日 } RTBlockHeader; //tick数据数据块 typedef struct _RTTickBlock : RTDayBlockHeader { WTSTickStruct _ticks[0]; //tick序列 } RTTickBlock;
上面的代码展示了实时
tick
数据存储模块的数据结构,文件头里面记录了当前数据的条数和当前映射的文件的数据容量,以及当前的交易日,文件后则跟随着连续的tick
数据结构。
交易日开始的时候,根据预设的容量,如1024
条tick
数据,计算一个初步的文件大小,然后将文件用mmap
映射到内存地址中,针对映射的内存地址做一个类型转换,就可以直接像访问内存对象一样对文件进行对读写了。如果在运行的过程中,数据超过容量上限,则重新扩展文件大小,扩展的策略基本如std::vector
,每次成倍扩展,然后再重新映射即可。
其他如实时的1分钟线
、5分钟线
的文件结构,也是如上的数据结构,只有具体的数据的结构不同。历史高频行情数据采用文件存储压缩后的二进制数据
typedef struct _BlockHeaderV2 { char _blk_flag[FLAG_SIZE]; //文件头的特殊编码,用于识别是否为自定义文件 uint16_t _type; //数据类型标记 uint16_t _version; //文件版本,不压缩为1,压缩存放为2 uint64_t _size; //压缩后的数据大小 } BlockHeaderV2; //历史Tick数据V2 typedef struct _HisTickBlockV2 : BlockHeaderV2 { char _data[0]; } HisTickBlockV2;
上面的代码展示了历史
tick
数据存储的文件结构。除了和实时数据一样的头部以外,历史高频数据还有一个size
用于标记后面压缩以后的数据的大小,并在读取的时候进行数据大小的校验。校验通过以后,对数据进行解压,解压完成以后,根据type
指定的数据类型,将解压的数据做一个类型转换,就能得到一个连续区块的历史高频数据。历史低频行情数据支持文件压缩存储和
MYSQL
存储typedef struct _BlockHeaderV2 { char _blk_flag[FLAG_SIZE]; //文件头的特殊编码,用于识别是否为自定义文件 uint16_t _type; //数据类型标记 uint16_t _version; //文件版本,不压缩为1,压缩存放为2 uint64_t _size; //压缩后的数据大小 } BlockHeaderV2; //历史K线数据V2 typedef struct _HisKlineBlockV2 : BlockHeaderV2 { char _data[0]; } HisKlineBlockV2;
如上面的代码所示,历史低频行情数据如K线数据,用文件存储的时候,文件结构的设计和历史高频数据是一样的。
但是低频数据也可以利用MYSQL
存储,存储的数据表格创建代码如下:CREATE TABLE `tb_kline_min5` ( `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, `exchange` VARCHAR(20) NOT NULL DEFAULT '' COLLATE 'utf8_general_ci', `code` VARCHAR(30) NOT NULL DEFAULT '' COLLATE 'utf8_general_ci', `date` INT(10) UNSIGNED NOT NULL DEFAULT '0', `time` INT(10) UNSIGNED NOT NULL DEFAULT '0', `open` DOUBLE(22,4) NOT NULL DEFAULT '0.0000', `high` DOUBLE(22,4) NOT NULL DEFAULT '0.0000', `low` DOUBLE(22,4) NOT NULL DEFAULT '0.0000', `close` DOUBLE(22,4) NOT NULL DEFAULT '0.0000', `volume` DOUBLE(22,6) UNSIGNED NOT NULL DEFAULT '0.000000', `turnover` DOUBLE(22,4) NOT NULL DEFAULT '0.0000', `interest` BIGINT(20) UNSIGNED NOT NULL DEFAULT '0', `diff_interest` BIGINT(20) NOT NULL DEFAULT '0', `createtime` DATETIME NOT NULL DEFAULT current_timestamp(), `updatetime` DATETIME NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`) USING BTREE, UNIQUE INDEX `exchange_code_date_time` (`exchange`, `code`, `date`, `time`) USING BTREE, INDEX `exchange_code` (`exchange`, `code`) USING BTREE ) COMMENT='5分钟线表' COLLATE='utf8_general_ci' ENGINE=InnoDB;
交易数据全部存储在内存中,每次登录成功以后,通过接口查询并重构内存中的数据,再定期做一个落地即可
{ "positions": [ { "code": "DCE.v.2105", "long": { "newvol": 0.0, "newavail": 0.0, "prevol": 2.0, "preavail": 2.0 }, "short": { "newvol": 0.0, "newavail": 0.0, "prevol": 0.0, "preavail": 0.0 } }, { "code": "SHFE.cu.2105", "long": { "newvol": 0.0, "newavail": 0.0, "prevol": 0.0, "preavail": 0.0 }, "short": { "newvol": 0.0, "newavail": 0.0, "prevol": 1.0, "preavail": 1.0 } } ], "funds": { "CNY": { "prebalance": 14530204.42, "balance": 14530204.42, "closeprofit": 0.0, "margin": 547022.7, "fee": 0.0, "available": 13983181.72, "deposit": 0.0, "withdraw": 0.0 } } }
上面展示了一个
simnow
账号接口拉取到的持仓和资金数据。此外还有成交数据和委托数据,基本格式如下:
成交明细:localid,date,time,code,action,volumn,price,tradeid,orderid 32921200,20210108,1610088820000,DCE.y.2105,开多,1,8160, 211111, 544405 45861050,20210111,1610347619000,SHFE.ru.2105,平今多,1,14040, 220809, 521384 45861053,20210111,1610347619000,SHFE.ru.2105,开空,2,14040, 220810, 521385 45861055,20210111,1610347619000,SHFE.hc.2105,平今多,6,4484, 220812, 521387 45861054,20210111,1610347619000,CZCE.OI.2105,平多,2,10103, 220811, 521386 45861056,20210111,1610347619000,SHFE.sp.2103,平今多,2,5962, 220813, 521388 45861058,20210111,1610347619000,CZCE.TA.2105,开空,2,3912, 220815, 521390 45861057,20210111,1610347619000,DCE.y.2105,平多,4,8082, 220814, 521389 45861061,20210111,1610347619000,DCE.c.2105,平多,13,2838, 220816, 521391 45861059,20210111,1610347619000,CZCE.FG.2105,平空,5,1794, 220817, 521392 45861060,20210111,1610347619000,CZCE.MA.2105,平空,4,2331, 220818, 521393 45861062,20210111,1610347619000,CZCE.RM.2105,平多,1,2988, 220819, 521394
委托明细:
localid,date,inserttime,code,action,volumn,traded,price,orderid,canceled,remark 32921200,20210108,1610088820000,DCE.y.2105,开多,1,1,8162, 544405,FALSE,全部成交报单已提交 45861050,20210111,1610347619000,SHFE.ru.2105,平多,1,1,14040, 521384,FALSE,全部成交报单已提交 45861053,20210111,1610347619000,SHFE.ru.2105,开空,2,2,14040, 521385,FALSE,全部成交报单已提交 45861055,20210111,1610347619000,SHFE.hc.2105,平多,6,6,4483, 521387,FALSE,全部成交报单已提交 45861054,20210111,1610347619000,CZCE.OI.2105,平多,2,2,10103, 521386,FALSE,全部成交报单已提交 45861056,20210111,1610347619000,SHFE.sp.2103,平多,2,2,5960, 521388,FALSE,全部成交报单已提交 45861058,20210111,1610347619000,CZCE.TA.2105,开空,2,2,3910, 521390,FALSE,全部成交报单已提交
回测环境下,产生的平台数据都存在内存中,回测结束以后,实时输出到文件中
策略成交:code,time,direct,action,price,qty,tag,fee CFFEX.IF.HOT,201909100940,SHORT,OPEN,3959.6,1,entershort,27.32 CFFEX.IF.HOT,201909191450,SHORT,CLOSE,3917,1,exitshort,27.03 CFFEX.IF.HOT,201909201345,LONG,OPEN,3935,1,enterlong,27.15 CFFEX.IF.HOT,201909201355,LONG,CLOSE,3929.6,1,exitlong,271.14 CFFEX.IF.HOT,201909201400,SHORT,OPEN,3925,1,entershort,27.08 CFFEX.IF.HOT,201909231435,SHORT,CLOSE,3878.6,1,exitshort,26.76
策略平仓:
code,direct,opentime,openprice,closetime,closeprice,qty,profit,totalprofit,entertag,exittag CFFEX.IF.HOT,SHORT,201909100940,3959.6,201909191450,3917,1,12780,12780,entershort,exitshort CFFEX.IF.HOT,LONG,201909201345,3935,201909201355,3929.6,1,-1620,11160,enterlong,exitlong CFFEX.IF.HOT,SHORT,201909201400,3925,201909231435,3878.6,1,13920,25080,entershort,exitshort CFFEX.IF.HOT,SHORT,201909241420,3904,201909251440,3888.2,1,4740,29820,entershort,exitshort CFFEX.IF.HOT,SHORT,201909251500,3875.6,201909271400,3858.8,1,5040,34860,entershort,exitshort CFFEX.IF.HOT,SHORT,201909300940,3850.4,201909300945,3858.2,1,-2340,32520,entershort,exitshort CFFEX.IF.HOT,SHORT,201909301000,3852.6,201910111130,3883.6,1,-9300,23220,entershort,exitshort
实盘环境下,产生的平台数据除了在内存中要保存以外,还要及时输出到文件中,并在下一次重启的时候,重新从文件中加载到内存中
{ "positions": [ { "code": "CFFEX.IF.HOT", "volumn": -5.0, "closeprofit": -929100.0000000036, "dynprofit": 89400.00000000056, "details": [ { "long": false, "price": 5806.6, "volumn": 5.0, "opentime": 202102181116, "opentdate": 20210218, "profit": 89400.00000000056, "maxprofit": 128400.00000000056, "maxloss": -3599.9999999994543, "opentag": "Q3_" } ] } ], "fund": { "total_profit": -929100.0000000036, "total_dynprofit": 89400.00000000056, "total_fees": 33604.200000000004, "tdate": 20210218 }, "signals": {}, "conditions": { "settime": 0, "items": {} } }
上面展示了策略实盘中的缓存数据,其中包括持仓数据、资金数据、信号列表以及条件单列表。
实盘数据处理框架
前面介绍了WonderTrader存储数据的机制,那么各种数据在WonderTrader中又是怎么样处理的呢?
1、行情数据读写分离
行情数据的读写分离指的是行情落地是一个进程,而行情使用又是另外一个进程。这是一个非常重要的机制,对于整个平台的架构都是一个关键的机制。
- 首先,读写分离的机制,可以设计成1+N的数据提供机制,很方便就能向多个框架运行的实例提供数据支持。
- 其次,策略在运行过程中,很难避免人工干预程序的运行,不管是出于风控的需要,还是调仓的需要。如果行情数据读写在一个进程中,那么在人工干预的时候,很容易就会造成数据丢失。而读写分离的机制,可以避免对数据落地进程的操作,而减小数据丢失的风险。
2、平台中的行情数据同步
笔者在跟一些朋友交流的过程中,经常会遇到同一个问题:on_bar
和on_schedule
有什么区别?为什么会有这样的问题呢?笔者对市面上很多量化平台都做了一个简单的调研,发现大多数平台只有on_bar
,所以一些朋友对WonderTrader的on_schedule
会表现出不理解的情况。
那么为什么WonderTrader会设计一个on_schedule
呢?
对于一个策略,可能会用到单个标的的多种周期的K线,也可能会用到多个标的相同周期的K线。但是最新的行情数据到达的时间有先有,有些不活跃的标的,甚至很长时间都没有最新的快照过来。如果策略以on_bar
作为重算的触发事件,可能就会遇到某些K线还没有真正的结束,等策略响应完了,这些标的的最后一笔tick
才进来。回测的时候,策略必然用的是正常结束的K线,而实盘时,这样的现象是非常不友好的,有时候甚至会造成非常坏的影响。
为了解决这样的问题,WonderTrader设计了一个机制,可以尽量保证在on_schedule
调用的时候,所有应当闭合的K线全部都已经触发了on_bar
,从很大程度上避免了前面提到的情况发生。(极端情况下,由于延时抖动等各种原因,也无法完全避免K线最后一笔tick
在策略重算过以后才收到的情况)这个机制的核心,还是依赖于tick
数据的时间戳,对K线进行同步回调。
3、平台数据管理
前面也提到,平台数据主要指的是策略的数据,包括持仓数据、成交数据、信号数据、回合数据以及每日绩效数据。这些数据的管理,也遵循读写分离的原则。
平台在运行的过程中不断的生成数据,并定时将数据写入到文件中。而wtpy提供的监控服务,作为平台数据的消费者,读取平台生产的数据,并向web
终端提供数据。整个过程,采用非入侵式的方式获取数据,而不对平台增加额外的工作,不影响平台的执行效率。
结束语
本文对WonderTrader的数据处理机制的介绍就到此结束了,希望能够对各位朋友有所启发。笔者水平有限,难免有错漏之处,还请各位朋友多多包涵指正。下一篇,笔者将围绕信号执行分离,来介绍WonderTrader的下单机制,望各位读者届时多多捧场。
最后再安利一下WonderTrader
WonderTrader旨在给各位量化从业人员提供更好的轮子,将技术相关的东西都封装在平台中,力求给策略研发带来更好的策略开发体验。
WonderTrader的github
地址:https://github.com/wondertrad...
WonderTrader官网地址:https://wondertrader.github.io
wtpy的github
地址:https://github.com/wondertrad...
市场有风险,投资需谨慎。以上陈述仅作为对于历史事件的回顾,不代表对未来的观点,同时不作为任何投资建议。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。