236 lines
13 KiB
Markdown
236 lines
13 KiB
Markdown
|
# 37 | Kafka & ZMQ:自动化交易流水线
|
|||
|
|
|||
|
你好,我是景霄。
|
|||
|
|
|||
|
在进行这节课的学习前,我们先来回顾一下,前面三节课,我们学了些什么。
|
|||
|
|
|||
|
第 34 讲,我们介绍了如何通过 RESTful API 在交易所下单;第 35 讲,我们讲解了如何通过 Websocket ,来获取交易所的 orderbook 数据;第 36 讲,我们介绍了如何实现一个策略,以及如何对策略进行历史回测。
|
|||
|
|
|||
|
事实上,到这里,一个简单的、可以运作的量化交易系统已经成型了。你可以对策略进行反复修改,期待能得到不错的 PnL。但是,对于一个完善的量化交易系统来说,只有基本骨架还是不够的。
|
|||
|
|
|||
|
在大型量化交易公司,系统一般是分布式运行的,各个模块独立在不同的机器上,然后互相连接来实现。即使是个人的交易系统,在进行诸如高频套利等算法时,也需要将执行层布置在靠近交易所的机器节点上。
|
|||
|
|
|||
|
所以,从今天这节课开始,我们继续回到 Python 的技术栈,从量化交易系统这个角度切入,为你讲解如何实现分布式系统之间的复杂协作。
|
|||
|
|
|||
|
## 中间件
|
|||
|
|
|||
|
我们先来介绍一下中间件这个概念。中间件,是将技术底层工具和应用层进行连接的组件。它要实现的效果则是,让我们这些需要利用服务的工程师,不必去关心底层的具体实现。我们只需要拿着中间件的接口来用就好了。
|
|||
|
|
|||
|
这个概念听起来并不难理解,我们再举个例子让你彻底明白。比如拿数据库来说,底层数据库有很多很多种,从关系型数据库 MySQL 到非关系型数据库 NoSQL,从分布式数据库 Spanner 到内存数据库 Redis,不同的数据库有不同的使用场景,也有着不同的优缺点,更有着不同的调用方式。那么中间件起什么作用呢?
|
|||
|
|
|||
|
中间件,等于在这些不同的数据库上加了一层逻辑,这一层逻辑专门用来和数据库打交道,而对外只需要暴露同一个接口即可。这样一来,上层的程序员调用中间件接口时,只需要让中间件指定好数据库即可,其他参数完全一致,极大地方便了上层的开发;同时,下层技术栈在更新换代的时候,也可以做到和上层完全分离,不影响程序员的使用。
|
|||
|
|
|||
|
它们之间的逻辑关系,你可以参照下面我画的这张图。我习惯性把中间件的作用调侃为:没有什么事情是加一层解决不了的;如果有,那就加两层。
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/f4/50/f49f221a8191d8fa95eeea146bbbf550.png)
|
|||
|
|
|||
|
当然,这只是其中一个例子,也只是中间件的一种形式。事实上,比如在阿里,中间件主要有分布式关系型数据库 DRDS、消息队列和分布式服务这么三种形式。而我们今天,主要会用到消息队列,因为它非常符合量化交易系统的应用场景,即事件驱动模型。
|
|||
|
|
|||
|
## 消息队列
|
|||
|
|
|||
|
那么,什么是消息队列呢?一如其名,消息,即互联网信息传递的个体;而队列,学过算法和数据结构的你,应该很清楚这个 FIFO(先进先出)的数据结构吧。(如果算法基础不太牢,建议你可以学习极客时间平台上王争老师的“数据结构与算法之美”专栏,[第 09讲](https://time.geekbang.org/column/article/41330)即为队列知识)
|
|||
|
|
|||
|
简而言之,消息队列就是一个临时存放消息的容器,有人向消息队列中推送消息;有人则监听消息队列,发现新消息就会取走。根据我们刚刚对中间件的解释,清晰可见,消息队列也是一种中间件。
|
|||
|
|
|||
|
目前,市面上使用较多的消息队列有 RabbitMQ、Kafka、RocketMQ、ZMQ 等。不过今天,我只介绍最常用的 ZMQ 和 Kafka。
|
|||
|
|
|||
|
我们先来想想,消息队列作为中间件有什么特点呢?
|
|||
|
|
|||
|
首先是严格的时序性。刚刚说了,队列是一种先进先出的数据结构,你丢给它 `1, 2, 3`,然后另一个人从里面取数据,那么取出来的一定也是 `1, 2, 3`,严格保证了先进去的数据先出去,后进去的数据后出去。显然,这也是消息机制中必须要保证的一点,不然颠三倒四的结果一定不是我们想要的。
|
|||
|
|
|||
|
说到队列的特点,简单提一句,与“先进先出“相对的是栈这种数据结构,它是先进后出的,你丢给它 `1, 2, 3`,再从里面取出来的时候,拿到的就是`3, 2, 1`了,这一点一定要区分清楚。
|
|||
|
|
|||
|
其次,是分布式网络系统的老生常谈问题。如何保证消息不丢失?如何保证消息不重复?这一切,消息队列在设计的时候都已经考虑好了,你只需要拿来用就可以,不必过多深究。
|
|||
|
|
|||
|
不过,很重要的一点,消息队列是如何降低系统复杂度,起到中间件的解耦作用呢?我们来看下面这张图。
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/f6/f0/f6a5e84033070f5ee7746ca85680aef0.png)
|
|||
|
|
|||
|
消息队列的模式是发布和订阅,一个或多个消息发布者可以发布消息,一个或多个消息接受者可以订阅消息。 从图中你可以看到,消息发布者和消息接受者之间没有直接耦合,其中,
|
|||
|
|
|||
|
* 消息发布者将消息发送到分布式消息队列后,就结束了对消息的处理;
|
|||
|
* 消息接受者从分布式消息队列获取该消息后,即可进行后续处理,并不需要探寻这个消息从何而来。
|
|||
|
|
|||
|
至于新增业务的问题,只要你对这类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,所以也就实现了业务的可扩展性设计。
|
|||
|
|
|||
|
讲了这么多概念层的东西,想必你迫不及待地想看具体代码了吧。接下来,我们来看一下 ZMQ 的实现。
|
|||
|
|
|||
|
### ZMQ
|
|||
|
|
|||
|
先来看 ZMQ,这是一个非常轻量级的消息队列实现。
|
|||
|
|
|||
|
> 作者 Pieter Hintjens 是一位大牛,他本人的经历也很传奇,2010年诊断出胆管癌,并成功做了手术切除。但2016年4月,却发现癌症大面积扩散到了肺部,已经无法治疗。他写的最后一篇通信模式是关于死亡协议的,之后在比利时选择接受安乐死。
|
|||
|
|
|||
|
ZMQ 是一个简单好用的传输层,它有三种使用模式:
|
|||
|
|
|||
|
* Request - Reply 模式;
|
|||
|
* Publish - Subscribe 模式;
|
|||
|
* Parallel Pipeline 模式。
|
|||
|
|
|||
|
第一种模式很简单,client 发消息给 server,server 处理后返回给 client,完成一次交互。这个场景你一定很熟悉吧,没错,和 HTTP 模式非常像,所以这里我就不重点介绍了。至于第三种模式,与今天内容无关,这里我也不做深入讲解。
|
|||
|
|
|||
|
我们需要详细来看的是第二种,即“PubSub”模式。下面是它的具体实现,代码很清晰,你应该很容易理解:
|
|||
|
|
|||
|
```
|
|||
|
# 订阅者 1
|
|||
|
import zmq
|
|||
|
|
|||
|
|
|||
|
def run():
|
|||
|
context = zmq.Context()
|
|||
|
socket = context.socket(zmq.SUB)
|
|||
|
socket.connect('tcp://127.0.0.1:6666')
|
|||
|
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
|||
|
|
|||
|
print('client 1')
|
|||
|
while True:
|
|||
|
msg = socket.recv()
|
|||
|
print("msg: %s" % msg)
|
|||
|
|
|||
|
|
|||
|
if __name__ == '__main__':
|
|||
|
run()
|
|||
|
|
|||
|
########## 输出 ##########
|
|||
|
|
|||
|
client 1
|
|||
|
msg: b'server cnt 1'
|
|||
|
msg: b'server cnt 2'
|
|||
|
msg: b'server cnt 3'
|
|||
|
msg: b'server cnt 4'
|
|||
|
msg: b'server cnt 5'
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
```
|
|||
|
# 订阅者 2
|
|||
|
import zmq
|
|||
|
|
|||
|
|
|||
|
def run():
|
|||
|
context = zmq.Context()
|
|||
|
socket = context.socket(zmq.SUB)
|
|||
|
socket.connect('tcp://127.0.0.1:6666')
|
|||
|
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
|||
|
|
|||
|
print('client 2')
|
|||
|
while True:
|
|||
|
msg = socket.recv()
|
|||
|
print("msg: %s" % msg)
|
|||
|
|
|||
|
|
|||
|
if __name__ == '__main__':
|
|||
|
run()
|
|||
|
|
|||
|
########## 输出 ##########
|
|||
|
|
|||
|
client 2
|
|||
|
msg: b'server cnt 1'
|
|||
|
msg: b'server cnt 2'
|
|||
|
msg: b'server cnt 3'
|
|||
|
msg: b'server cnt 4'
|
|||
|
msg: b'server cnt 5'
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
```
|
|||
|
# 发布者
|
|||
|
import time
|
|||
|
import zmq
|
|||
|
|
|||
|
|
|||
|
def run():
|
|||
|
context = zmq.Context()
|
|||
|
socket = context.socket(zmq.PUB)
|
|||
|
socket.bind('tcp://*:6666')
|
|||
|
|
|||
|
cnt = 1
|
|||
|
|
|||
|
while True:
|
|||
|
time.sleep(1)
|
|||
|
socket.send_string('server cnt {}'.format(cnt))
|
|||
|
print('send {}'.format(cnt))
|
|||
|
cnt += 1
|
|||
|
|
|||
|
|
|||
|
if __name__ == '__main__':
|
|||
|
run()
|
|||
|
|
|||
|
########## 输出 ##########
|
|||
|
|
|||
|
send 1
|
|||
|
send 2
|
|||
|
send 3
|
|||
|
send 4
|
|||
|
send 5
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
这里要注意的一点是,如果你想要运行代码,请先运行两个订阅者,然后再打开发布者。
|
|||
|
|
|||
|
接下来,我来简单讲解一下。
|
|||
|
|
|||
|
对于订阅者,我们要做的是创建一个 zmq Context,连接 socket 到指定端口。其中,setsockopt\_string() 函数用来过滤特定的消息,而下面这行代码:
|
|||
|
|
|||
|
```
|
|||
|
socket.setsockopt_string(zmq.SUBSCRIBE, '')
|
|||
|
|
|||
|
```
|
|||
|
|
|||
|
则表示不过滤任何消息。最后,我们调用 socket.recv() 来接受消息就行了,这条语句会阻塞在这里,直到有新消息来临。
|
|||
|
|
|||
|
对于发布者,我们同样要创建一个 zmq Context,绑定到指定端口,不过请注意,这里用的是 bind 而不是 connect。因为在任何情况下,同一个地址端口 bind 只能有一个,但却可以有很多个 connect 链接到这个地方。初始化完成后,再调用 socket.send\_string ,即可将我们想要发送的内容发送给 ZMQ。
|
|||
|
|
|||
|
当然,这里还有几个需要注意的地方。首先,有了 send\_string,我们其实已经可以通过 JSON 序列化,来传递几乎我们想要的所有数据结构,这里的数据流结构就已经很清楚了。
|
|||
|
|
|||
|
另外,把发布者的 time.sleep(1) 放在 while 循环的最后,严格来说应该是不影响结果的。这里你可以尝试做个实验,看看会发生什么。
|
|||
|
|
|||
|
你还可以思考下另一个问题,如果这里是多个发布者,那么 ZMQ 应该怎么做呢?
|
|||
|
|
|||
|
### Kafka
|
|||
|
|
|||
|
接着我们再来看一下 Kafka。
|
|||
|
|
|||
|
通过代码实现你也可以发现,ZMQ 的优点主要在轻量、开源和方便易用上,但在工业级别的应用中,大部分人还是会转向 Kafka 这样的有充足支持的轮子上。
|
|||
|
|
|||
|
相比而言,Kafka 提供了点对点网络和发布订阅模型的支持,这也是用途最广泛的两种消息队列模型。而且和 ZMQ 一样,Kafka 也是完全开源的,因此你也能得到开源社区的充分支持。
|
|||
|
|
|||
|
Kafka的代码实现,和ZMQ大同小异,这里我就不专门讲解了。关于Kafka的更多内容,极客时间平台也有对 Kafka 的专门详细的介绍,对此有兴趣的同学,可以在极客时间中搜索“[Kafka核心技术与实战](https://time.geekbang.org/column/intro/191)”,这个专栏里,胡夕老师用详实的篇幅,讲解了 Kafka 的实战和内核,你可以加以学习和使用。
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/d4/27/d401e91a10826773067857e3c9974a27.png)
|
|||
|
|
|||
|
来自极客时间专栏“Kafka核心技术与实战”
|
|||
|
|
|||
|
## 基于消息队列的 Orderbook 数据流
|
|||
|
|
|||
|
最后回到我们的量化交易系统上。
|
|||
|
|
|||
|
量化交易系统中,获取 orderbook 一般有两种用途:策略端获取实时数据,用来做决策;备份在文件或者数据库中,方便让策略和回测系统将来使用。
|
|||
|
|
|||
|
如果我们直接单机监听交易所的消息,风险将会变得很大,这在分布式系统中叫做 Single Point Failure。一旦这台机器出了故障,或者网络连接突然中断,我们的交易系统将立刻暴露于风险中。
|
|||
|
|
|||
|
于是,一个很自然的想法就是,我们可以在不同地区放置不同的机器,使用不同的网络同时连接到交易所,然后将这些机器收集到的信息汇总、去重,最后生成我们需要的准确数据。相应的拓扑图如下:
|
|||
|
|
|||
|
![](https://static001.geekbang.org/resource/image/ab/dc/ab08e31f805d1f7ea596ae4ccdea48dc.png)
|
|||
|
|
|||
|
当然,这种做法也有很明显的缺点:因为要同时等待多个数据服务器的数据,再加上消息队列的潜在处理延迟和网络延迟,对策略服务器而言,可能要增加几十到数百毫秒的延迟。如果是一些高频或者滑点要求比较高的策略,这种做法需要谨慎考虑。
|
|||
|
|
|||
|
但是,对于低频策略、波段策略,这种延迟换来的整个系统的稳定性和架构的解耦性,还是非常值得的。不过,你仍然需要注意,这种情况下,消息队列服务器有可能成为瓶颈,也就是刚刚所说的Single Point Failure,一旦此处断开,依然会将系统置于风险之中。
|
|||
|
|
|||
|
事实上,我们可以使用一些很成熟的系统,例如阿里的消息队列,AWS 的 Simple Queue Service 等等,使用这些非常成熟的消息队列系统,风险也将会最小化。
|
|||
|
|
|||
|
## 总结
|
|||
|
|
|||
|
这节课,我们分析了现代化软件工程领域中的中间件系统,以及其中的主要应用——消息队列。我们讲解了最基础的消息队列的模式,包括点对点模型、发布者订阅者模型,和一些其他消息队列自己支持的模型。
|
|||
|
|
|||
|
在真实的项目设计中,我们要根据自己的产品需求,来选择使用不同的模型;同时也要在编程实践中,加深对不同技能点的了解,对系统复杂性进行解耦,这才是设计出高质量系统的必经之路。
|
|||
|
|
|||
|
## 思考题
|
|||
|
|
|||
|
今天的思考题,文中我也提到过,这里再专门列出强调一下。在ZMQ 那里,我提出了两个问题:
|
|||
|
|
|||
|
* 如果你试着把发布者的 time.sleep(1) 放在 while 循环的最后,会发生什么?为什么?
|
|||
|
* 如果有多个发布者,ZMQ 应该怎么做呢?
|
|||
|
|
|||
|
欢迎留言写下你的思考和疑惑,也欢迎你把这篇文章分享给更多的人一起学习。
|
|||
|
|