【核心技术】高精回测之三数据的极速IO

wei-jianan/核心开发

高效的时序数据库

当我们在说数据库的时候,我们在说什么? 增,删,改,查,事物,持久化。而到了量化这个场景,具体到回测这个场景,数据使用的场景是非常典型的。不论我们把策略抽象成状态机,还是把交易决策抽象成MDP,总归是时序的。 基于当下,面相未来。区别只是,也许这个策略需要时序的读取这段数据,那个策略需要更多的某几段数据。 带入一下回测和实盘两种场景,实盘—顺序尾部增量读取数据, 回测—-找到某段或多段数据的区间顺序读取。

先贤曾有言,现代软件优化,只有一个思想—-加缓存,一层缓存不够,再加一层。

由此,计算机从磁盘开始,依次向上,有了SSD,内存,L3L2L1缓存,寄存机等等。为了研究时序的数据,挖掘其中的alpha,回测自己的策略,研究员|策略师们绞尽了脑汁。这期间还包含着与工程人员,数据组的重重矛盾。

场景一:

公司刚刚成立,数据到处买。 研究员|策略师对着一个百度云盘的链接,寂寥的等待几十个G的csv逐渐下载到本地陷入了沉思。最终写好了一个程序

# research.py
dataframes = [pd.read_csv(csv_path) for csv_path in csv_paths]
joined_df = pd.concat(dataframes).sort_values(by="data_time")
# time to hack the joined_df
# 计算各种指标

cool! 两行代码把几十个G的csv 倒到一张表里面,翻开了pandas的文档立刻开始工作。就是每次改两行这个 research.py, 也没干啥事儿,就是得等好几十分钟。一看,原来时间主要耗费在等这几十G的文件加载到内存里啊。所谓计算一分钟,加载一小时。好家伙swap都用上了,赶紧买内存条,不过这每次都磁盘读一次也不是个事儿啊。于是

有了场景二:

公司攒了几个人买了大型的服务器,512G的内存尽管用。此外,方才反复磁盘读的原因是每次执行完了research.py进程退出了,辛辛苦苦的磁盘读浪费掉了。因此急智的研究员|策略师打开了 jupyter notebook, 既然进程一退出,磁盘就白读了,那咱就想办法不让进程退出就完了。在一个notebook cell里面可劲儿研究。随着公司规模的扩大,招了3-4个研究员共用这太服务器,每个研究员的research.py越来越大,cell越来越多,jupyter notebook的停留时间越来越长,各自需要的数据各有不同。于是时不时的ipython kernel就开始崩溃,代码管理不清楚,csv到处重复存储。 是内存也不够,磁盘空间也开始不够,服务器还时常OOM崩溃重启。

有了场景三:

数据管理不过来了,公司专门招募了数据团队,P7P8的数据湖专家隆重登场。看到到处都是重复的csv怒从心中起。这么一堆数据到处乱放怎么回事儿,不是数据多吗,Hadoop,Spark,Flink之类的东西看情况用吧。每个研究员自己分一台虚拟机,想要什么数据到这个分布式的数据中心,写SQL来请求吧。数据团队开始从头搭建整个ETL流程。研究员们开始细究十行以上的SQL该如何写,如何高效的写出优雅又易于管理的SQL,成为了各自的alpha来源。只是这大数据虽然稳健虽然大,SQL执行起来还是慢啊。后续继续研究,找到了DolphineDB,KDB等等时序数据库,又开始琢磨分布式的问题。

总结一下数据方面的总需求,也就是实现的优化目标。

1. 数据的组织形式应当面向读取 尤其是顺时序读取,理应做到高效,应当充分使用磁盘连续读场景下的最高性能。高吞吐,首包延迟低(立等可取不预热预加载到内存),资源占用少(不买内存条)。这是单机性能上的要求。

2. 数据的存储形式应当尽可能灵活, 支持各种各样的数据类型,同时尽量与实盘存储形式对齐。尽量让回测/研究阶段的代码,在实盘阶段多复用。这是使用灵活性上的要求。

3. 数据应当统一管理。 各自存一份csv,到处重复,各自有各自的解析方式,不准确。这是数据准确性上的要求。

4. 数据中心应当是分布式的,具备应对多研究员同时研究的能力。数据应当具备一定的可用性以及一致性,分区容忍可以放宽要求。这是在多用户场景下的可用性上的要求。

综合以上需求,回顾本文第一段,对数据库的一些基本功能做一些裁剪,也就是放宽一些约束条件。删?历史行情不用变我们不删;改,同理历史行情不用变我们不改;查,我们不需要O(1) 或者O(log(n)) 查,只需要顺序的区间查;增,只需要尾部新增的APPEND操作。事物?状态转移都是原子的,不需要事物。持久化?当然。

毫无疑问这是个时序数据库,但如果数据在内存中,起码,部分的在内存中,方能达到极限的读取速度。既然在内存中了,为什么不可以用来通信呢? 实盘中策略无非是在尾部新增读行情数据和回报数据罢了。 既然实盘可以将该时序数据库用作通信的FIFO队列,那么放宽一些对持久化对 failed tolerance的帮助,获得尽量低的延迟似乎是一个不错的技术取舍。就目前而言的需求,我们似乎在描述一个,不限长度的, 可以持久化,可以性能尚可的按时间查询的,大队列(顺序读,尾部写)。

按照本系列的惯例,场景需求描绘清楚了就该讲讲技术设计层面的抽象了,依旧不聊过多的底层技术细节。先荡开一笔,回到Unix的设计哲学。Unix当中,起码早期的Unix-like系统中,一切皆文件。文件作为一个极其重要的抽象,不仅普通的文件,目录、字符设备、块设备、 套接字等,在Unix中都可以以一套统一的api操作。我们不关心文件作为与硬件交互的抽象,单单关注文件在存储这个场景,文件这个抽象的特性,总的来说就是一串字节流,并且当我们操作这段字节流|字节序列时,我们不关心硬件的资源管理。当使用open,read,write,close时,我们无需关注硬件资源的分配,page与页表等等操作系统的概念,一口气写了太多字节超出了当前页时如何分配新页,写完了以后何时持久化到磁盘,何时更新meta信息等等。裁剪到一些纷繁复杂的posix api,只留下,open,close,putc(顺序写),getc(顺序读),lseek(重定位offset)。看看这几个api,把每个字节视作带时戳的事件数据。一个不限长度的, 可以持久化,可以性能尚可的按时间查询的,大队列(顺序读,尾部写)是不是就做出来了?在量化这个领域,时序数据库,只需要有文件–不定长时间序列的逻辑抽象,页—固定大小存储的物理抽象。两者配合,实现类似open,close,putc,getc,lseek的五个接口,一切就搞定了。

初看似乎如果行情接收并写入我们的时序数据库仅在一个线程中完成,以上的做法即可轻松实现。但受限于我国的柜台机制,程序员无法简单的通过IO多路复用等技巧控制数据的接收与解析,通常来说行情数据的接收很可能在多个不同的线程中完成。这个队列受到了线程安全问题的挑战。那么要么起码需要使getc是线程安全的,也就是MPMC(多生产者,多消费者)队列,要么可以同时聚合读多个SPMC(单生产者,多消费者)队列。鉴于我们在说时序数据库,以及回测场景。接下来,上文的所述的这些队列,会逐渐过渡到用数据库的“表”来代称。

当我们说策略在读数据的时候,我们在说以下具体的需求,于是产生了新的,更domain-specific的约束。

1)读多种金融产品的行情

2)读某个金融产品的多种行情(委托,成交,切片,Ticker等)

3)以上的各种数据聚合起来按照严格接收时序被策略读

4)如果在回测场景,以上数据在某个设定好的起止时间内区间顺序读,如果是实盘场景,以上数据则是严格尾部新增读。

有人看不懂,所以先举个例子,实盘中我们也许可以使用MPMC,也可以是多个SPMC队列,但总的来说,当一个策略实例从中读到其所不感兴趣的数据时,一个简单的filter操作直接过滤掉即可,一般来说是一个O(1) 操作,我们就算耗时1微妙,这个处理速度也远远快过数据到达的吞吐量—-多数情况下线程只是在空转忙等而已。但如果是回测,如果一个单标的的CTA策略,需要将全市场的数据顺序读一次,再根据产品名称filter出感兴趣行情,那显然极不划算。

鉴于1) 2)和我们购买到的历史数据通常都是分标的 分类型存储的,以及策略梭订阅的金融产品以及行情类型是不可知的。为了方便管理,维护,修改,也为了提升回测效率(到一个巨大的csv中一行一行filter掉我们不需要的行,是低效的)。数据应当根据 (金融产品 行情类型) ,分表存储。鉴于3)应当支持数据库中的JOIN表操作,将多个表聚合并顺时序读取。 鉴于4)前文已述lseek越快越好。

JOIN表并且按照时序顺序读是个不能更简单的算法题。鉴于每张表都是时序存储的假设成立,一个理想的JOIN复杂度应当是归并排序中的merge操作的复杂度,不展开了。但在这个文件–不定长时间序列的逻辑抽象中, 高性能的依据时间戳来lseek并不容易,但这里同样不展开了。

写成伪代码,如果前文没看懂在说什么,看下这个代码再回头看一遍,应该就能懂了:

# 回测 JOIN 多表
joined_seq = JOIN([symbol1_ticker_seq, symbol2_snap_seq])
LSEEK(joined_seq, backtest_start_time)
data_point = GETC(joined_seq)
while data_point.time_stamp < backtest_end_time:
    data_point = GETC(joined_seq)):
    if not filter_func(data_point):
        continue
    if isinstance(data_point, Ticker):
        strategy.on_ticker(data_point)
    elif  isinstance(data_point, SnapShot):
        strategy.on_snapshot(data_point):
    else:
        pass
            
# 实盘 多SPMC队列
symbol1_ticker_queue, symbol2_snap_queue
while True:
    ticker = GETC(symbol1_ticker_queue)
    if ticker: 
        strategy.on_ticker(ticker)
    snapshot = GETC(symbol2_snap_queue)
    if snapshot: 
        strategy.on_snapshot(snapshot)

# 得到反馈,有些原本对量化系统不知全貌的潜在老板,看到这个代码突然醒悟了,
  觉得融会贯通能够单挑全部了,可以立刻雇人自行from scratch研发了。
  相信我,这是幻觉,比方说nginx,或者redis,把核心思路写成伪代码,
  也差不多就这样。从头做,投入再多的💰,也无法避免数年的踩坑。
  "多的是,你不知道的事~"
 
# 正好如果前文没读懂状态机,可以拿这个来理解状态机。

接下来存在如下的数据管理和使用的进阶问题。

1.假设已经准备好了百T的十年逐笔数据,,突然发现数据供应商的某个月份的数据有误,于是仅仅需要更改这个月份的数据。那么显然,针对顺序读,通过彻底的连续存储特化的时序数据库在此就遇到了问题。换句话说,此时遇到了数据库中“改”的需求。架构设计中的trade-off第一次在本系列中出现了。应该引入更复杂的数据结构,放弃一定的顺序读性能?还是保证读性能的极致,放弃一部分数据的可维护性呢?开个玩笑,成年人都要的原则依旧成立。但是这次就不展开了,给个提示,可以构造某个数据的元信息 –> 文件的单射。这个单射即应当易于理解,同时是可编程的,就可以满足方方面面的要求了。这里看不懂没关系。

2.实盘录制的数据固然最好,甚至可以直接用来实现本系列的第一篇文章里提到的回放功能。但显然,实盘数据不一定严格按照(金融产品 行情类型) ,分表存储 。回测/回放固然直接在实盘录制的数据上进行最好。但同时对于专门为了回测准备的数据形式,回测框架应当也能够支持,且出于兼容性的if else代码越少越好,否则难于维护。我们不能假设框架的用户—研究员,接交易所的开发,数据团队,对于数据遵循同一种编排标准。唯一的假设只有,在任意一个不定长序列的逻辑抽象中,每一个元素都是合法的数据,且按照时序存储。

3. 如果数据不能全部,甚至大部分都不能来自于实盘录制。而为了追求高效和灵活,数据肯定有一套序列化规范。而数据的来源很有可能是异构的(CSV,FTP,MySql),到底是什么样的序列化规范和组织形式,我们无从得知用户将要面对的无穷痛苦。因此应当有一个可编程的工具集,可以灵活的将异构的数据源转化为我们需要的存储形式。

4. 一个很简单的回测API设计是这样的, StrategyRunner.add_strateg(stg_obj).run_backtest(begin: datetime, end: datetime, symbol : List[Text], …), cool。交代了回测的策略实例(初始状态),起止时间 + 订阅的金融产品(输入 | 事件序列),框架和策略成员函数提供状态转移,简洁明了。但这里有个问题,我相信无数做期货类跨期套利策略的团队都会遇到—-换期。实盘的时候一旦换期日来临,兵荒马乱鸡飞狗跳,平时里高妙的数学建模,无人值守的精美代码,此时都不可靠了起来,团队人心惶惶,无心研究,时刻准备手动介入,甚至压根就是手动换完,策略程序修改近远期产品名配置,直接重开。这时就遇到了,手动换也行,但是执行成本到底是正是负,对辛苦研究的alpha有多少损益,无人知晓。做完回顾性的复盘,这次手动执行亏了,下次也不见的能更好。此外,回测此时就不得不被迫分段按周,按月,按季度进行。各种算夏普卡玛的指标,就不得不拼接重算。并且这种用法,距离我心目中,回测完几年,只要夏普达到要求,就可以上市盘直接跑躺赚的期望尚有距离—实盘回测一套代码的愿景,从未曾被放弃。

如果策略可以做到在运行时动态订阅新标的,把可配置进一步推广到可编程,那么是不是一个可以长期运行,可以运行时换期的策略就出现了?虽然换期部分的代码编写涉及到近远期产品名的构造等等,是有些复杂,但这点复杂度换来最终的省时省力,同时又有可以完整进行数年的回测作为保证,完全划得来。

多表JOIN的操作,是个数据库都可以做,但是运行时动态JOIN的操作,就无法在一条SQL中体现了吧,嘿嘿。

好了,IO讲完了,本节的内容比较多,如果专门做量化策略研究的同学们,看个大概,知道核心开发部分有什么样的难点,需要解决什么样的问题即可。仰望星空(抬头看路),脚踏实地(低头拉车)。

最后补充一下这个所谓时序数据库的用户视角。用户有三者,数据团队,quant dev, 研究员|策略师。其中研究员|策略师,策略实现的dev,完全不需要关注该数据库的使用方法,一切都应当隐藏在既有的几个标准API下,不需要考虑JOIN,动态JOIN,如何读,如何写,可以直接用实盘风格的代码开始研究alpha,回测赚钱,然后直接上实盘。对于接交易所的quant dev,同样不需要对底层原理和使用细节有过多的理解,只需要清楚队列的概念,顺序地调用putc即可。 唯一需要对原理和细节有比较深刻理解的是数据团队,负责使用工具链将异构数据库ETL到目标的存储形式中。当然实际上也可以不用。这就涉及到——

总需求的3与4—数据应当做到中心化的一致性和高可用,我们后文再讲。