头图

image

前言

  《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最终也是采用的文件存储的方式,虽然中间兜了一个大圈才绕回来。
  • 关系数据库

    关系数据库,作为数据存储的传统主力,一直占用一席之地。关系数据库,如MYSQLMSSQLOracle等,对于结构化的数据非常友好,虽然读写效率不如文件存储,因为要建立索引,还要引入额外的空间占用,但是对于大部分低频数据的存储还是能够满足的。数据库存储有一个最好的好处就是一般数据库都有可视化管理工具,非常方便对数据进行管理。如果要搭建一个投研平台,要方便团队内部成员查看数据的话,那么关系数据库会是一个不错的选择。WonderTrader的历史数据也支持MYSQL存储的方式。
  • 分布式数据库

    分布式数据库是时下大数据浪潮下的宠儿。笔者之前在一家量化私募工作的时候,专门调研过当时比较主流的一些分布式数据库,包括HadoopCassandraMysql集群等。笔者认为,分布式数据库存储的实现和关系型数据库没有太大的差别,而分布式数据库的核心在于数据的安全性(有备份)和事务处理的并发性。对于量化平台来说,数据存储不会有特别复杂的业务逻辑,所以不用担心分布式事务这方面的问题,核心关注的点还是在于查询数据可以在多个节点并发执行,从而提高效率。笔者认为,分布式数据库是比较适合较大的量化团队或者研究团队使用的,因为团队成员多,各类数据的总量也会非常巨大,分布式数据库能够轻松胜任这样对的应用场景。

  此外,目前还流行一种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数据结构。
    交易日开始的时候,根据预设的容量,如1024tick数据,计算一个初步的文件大小,然后将文件用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_baron_schedule有什么区别?为什么会有这样的问题呢?笔者对市面上很多量化平台都做了一个简单的调研,发现大多数平台只有on_bar,所以一些朋友对WonderTraderon_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旨在给各位量化从业人员提供更好的轮子,将技术相关的东西都封装在平台中,力求给策略研发带来更好的策略开发体验。

WonderTradergithub地址:https://github.com/wondertrad...

WonderTrader官网地址:https://wondertrader.github.io

wtpygithub地址:https://github.com/wondertrad...


市场有风险,投资需谨慎。以上陈述仅作为对于历史事件的回顾,不代表对未来的观点,同时不作为任何投资建议。


WonderTrader
31 声望23 粉丝