为什么要封装API/封装后API的工作流程

606 阅读16分钟

为什么要封装API

直接原因就是C++的API没法直接在Python里用,不过这个回答有点太简单,这里我们稍微做一些拓展解释:

1、C++ API中很多函数的调用参数是结构体,而在Python中我们既无法直接创建这些结构体(主动函数),也无法提取结构体中包含的数据(回调函数)。

2、Python虚拟机是基于C语言实现的,所有的Python对象,哪怕只是一个整数或者字符串,在C的环境中都是一个PyObject对象(好吧,我知道C里没有对象,只有结构体,但估计90%的读者都不在乎这个区别)。用户如果在Python中直接传递一个参数到C++环境里,C++是无法识别的(Python:买入1手股指, C++:你要买入多少?)。

3、Python只能加载封装为PyObject对象的模块,因此原生C++的API在Python中连加载都加载不了。


封装后电商API的工作流程

主动函数

1、用户在Python程序中调用封装API的主动函数,并直接传入Python变量(PyObject对象)作为参数。

2、封装API将Python变量转换成C++变量。

3、封装API调用原生API的主动函数,并传入C++变量作为参数。

回调函数

1、交易柜台通过原生API的C++回调函数推送数据信息,传入参数为C++变量

2、封装API将C++变量转换为Python变量

3、封装API调用封装后的回调函数向用户的Python程序中推送数据,传入参数为Python变量

名词定义

  • 封装API:指的是经过封装后,可以直接在Python中使用的API

  • 原生API:指的是由软件公司提供,在C++中使用的API

  • Python变量:包含Python中的数字、字符串、对象等等

  • C++变量:包含C++中的内置数据类型和结构体等


    从Python的角度看原生API的一些问题

1、原生的API中每个功能分为了两个类:分别是包含回调函数的Spi类和主动函数的Api类,这种设计能让用户更好的分清不同的功能。但是从面向对象的角度,把两个类封装到一起更为方便,实际使用中绝大部分C++的用户也会将接口整合到一个类里面(可以参见网上很多CTP开发的示例代码),因此Python的API中,我们也会将Spi和Api两个类的功能封装到一个类中。

2、原生的API中回调函数被触发后必须快速返回,否则会导致其他数据的推送被阻塞,阻塞时间长了还有可能导致API发生崩溃,因此回调函数中不适合包含耗时较长的计算逻辑。例如某个TICK行情推送后,如果用户在回调函数中写了一些比较复杂的计算(循环计算等等),耗时超过3秒(这个数字只是笔者的一个经验),则在这个3秒中,其他的行情推送用户是收不到的(被阻塞了),且很可能3秒后会出现API崩溃(程序死掉)。这里的解决方案是使用生产者-消费者模型,在API中包含一个缓冲队列,当回调函数收到新的数据信息时只是简单存入缓冲队列中并立即返回,而数据信息的处理和向Python中的推送则由另一个工作线程来执行。

3、API的函数中使用了大量的结构体用于数据传送,这在C++而言是非常自然的设计,但是对Python封装会造成不小的麻烦,所有的结构体都要封装成对应的Python类,工作量太大也非常容易出错。这点我们可以利用Python相对于C++更为高级的数据结构来解决,Python中的dict字典本质是一个哈希表,但是同一个字典内键和值的类型允许不同,这个特性使得字典可以非常方便的用来代替C++的结构体。

明确了以上的问题后,我们就可以开始着手设计Python API的结构了。


Python API的结构设计(以行情API即MdApi为例阐述)

这里用行情API作为示例,下图展示了封装行情API的整体结构,…表示省略。注意原生API的函数名开头都是大写字母,为了便于分辨以及符合Python的PEP8编码规则,作者的函数都以小写字母开头。代码中采用的示例是用户登陆UserLogin这个功能。

图片

图片


****封装中的类和函数命名规则:

  • 封装API的类取名为MdApi,注意这个不是原生API中的CSecurityFtdcMdApi。
  • 原生API中的主动函数(如ReqUserLogin)对应的封装后API中的主动函数改为首字母小写(如reqUserLogin)。
  • 原生API中的回调函数(如OnRspUserLogin)已经在继承子类MdApi中虚函数重写,其作用是将回报类型及回报内容组装成task结构体并压入任务队列中。
  • 与线程相绑定的线程函数是processTask(),其不断监听任务队列,根据监听到的task结构体,选择不同的具体任务处理函数,如举例中的processRspUserLogin()。
  • **onRspUserLogin()这类构成具体任务处理函数的实体部分,其在MdApi中是纯虚函数,其具体定义依靠类继承并具体实现,相关具体实现往往在XXgateway.py中。
    **

****MdApi的成员变量:

  • api:原生API中的CSecurityFtdcMdApi对象,用于实现主动函数的调用。
  • task_thread:一个boost线程指针,用于实现任务线程的工作。
  • task_queue:一个线程安全的任务队列。

****工作步骤,以登陆为例阐述:

  1. 用户在Python中调用reqUserLogin函数,传入参数为包含登陆信息(用户名、密码)的字典req以及本次请求号nRequestID,该函数自动将字典中的信息提取并创建原生API使用的结构体后,调用原生API的主动函数ReqUserLogin来进行登录。
  2. 登录成功后,原生API会调用OnRspUserLogin的回调函数返回登录信息(注意这里On是大写),这里其实是对原生API的回调函数进行了虚函数重写。在回调函数里,只是简单的把结构体数据保存到一个任务对象Task中,并推送到任务队列里。
  3. 工作线程中运行的函数是processTask,该函数负责检查任务队列中是否有新的任务,如果有则调用对应的process函数进行处理,如果没有则阻塞等待。
  4. processTask函数检查到任务队列中OnRspUserLogin推送的一个任务后,调用processRspUserLogin函数进行处理。该函数首先从结构体中提取数据并转换为Python字典,然后调用onRspUserLogin函数(这里的on是小写)推送到Python环境中,onRspUserLogin函数由用户在Python中继承实现。


Python API的具体实现( 以行情API即MdApi为例阐述

构造、析构函数

图片


构造函数中仅包含了创建一个工作函数为processTask的工作线程,并将该线程的指针绑定到task_thread上。

析构函数为空,用户在退出前应当主动调用安全退出函数(参见源代码中的exit)。

主动函数


图片

原生API中的请求登录函数为ReqUserLogin,传入的参数一共包含两个:一个CSecurityFtdcReqUserLoginField 结构体的指针,一个代表请求编号的整数。

封装后的API函数为reqUserLogin,传入参数同样为两个:一个Python字典对象、一个整数。reqUserLogin函数会从Python字典对象中根据键值依次提取结构体中对应的数据。如结构体中有一个成员叫做BrokerID,则使用getChar函数从字典对象中提取”BrokerID”键对应的值。

**在将python类型数据转换为C++要求的类型数据时,上述就涉及到getChar()自定义函数,当然还有其它,具体见源代码。
**

原生API回调函数(已虚函数重写)

图片


当登录成功后,原生API中的回调函数OnRspUserLogin会被自动调用,通知用户登录相关的信息,传入参数包括四个,分别为:

CSecurityFtdcRspUserLoginField结构体指针pRspUserLogin:用户本次登录的相关信息;

CSecurityFtdcRspInfoField结构体指针pRspInfo:登录是否存在错误的相关信息;

整数nRequestID:登陆请求编号;

布尔值bIsLast:是否为该请求的最后一次通知;

在回调函数中,我们通过创建一个Task对象来保存这些信息,并推入task_queue中,等待工作线程的提取处理。其中,由于pRspInfo可能存在空指针的情况,所以需要进行判断,若指针为空,则在Task对象上绑定一个内容为空的CSecurityFtdcRspInfoField结构体(这步等于一个异常情况的处理)。

ONRSPUSERLOGIN是一个整数常量(在头文件中定义),用于标识该Task对象包含的是哪个回调函数返回的信息。

Task对象的定义如下:

图片

其中any是boost库中的any类,作用是定义一个可以存放任意类型数据的变量(有点类似于Python里的变量),但是当用户尝试从该变量中获取原本的数据时,需要知道原本数据的类型。原生API中不同回调函数返回的参数类型是不同的,因此为了提高代码的简洁性选择使用boost.any这个泛型类。

任务处理函数 processTask()及具体任务处理函数

首先是负责从任务队列中提取任务,并根据任务名称的不同使用对应的函数进行处理的processTask函数:

图片

****使用while (1)的方式让processTask处于无限循环中不断运行,从task_queue队列中提取任务对象task后,使用swtich根据任务的回调函数名称task_name,调用对应的函数处理该任务。上面的例子中,当程序检查task_name是ONRSPUSERLOGIN这个常量值后,就会调用processRspUserLogin函数进行处理,其代码如下:

图片

****any_cast函数由boost.any库提供,作用之前提到的从any变量中提取出用户需要的数据类型来。dict类由boost.python库提供,使用dict可以直接创建Python环境中的字典,同时当我们使用d[key] = value这种语句进行赋值时,dict中的key和value均会自动转换为对应的Python对象。当我们将返回的业务信息CSecurityFtdcRspUserLoginField结构体和错误信息结构体CSecurityFtdcRspInfoField分别转换为data和error这两个Python字典后,我们就可以通过onRspUserLogin回调函数推送到Python环境中了。

具体任务处理函数的实体内容


图片

其在MdApi的定义中为需要重载的虚函数,我们往往需要在XXgateway.py中,通过继承MdApi类并重写该虚函数,可以实现对从柜台回报的具体信息的处理。



底层接口对接

通过前面,我们已经获得了和原生C++ API功能完全相同的Python封装API。通常情况下,为了将某个API对接到我们的程序中,需要以下两步:

1、将API的回调函数收到的数据推送到程序的中层引擎中,等待处理

2、将API的主动函数进行一定的简化封装,便于中层引擎调用

v n.lts中的API接口在使用时 需要由用户类继承后对父类中的纯虚函数进行具体实现,这里所说的父类中的纯虚函数是指构成具体任务处理函数实体内容的那部分纯虚函数。下面的内容以行情接口MdApi为例。

接口对象设计(涉及类继承)

class DemoMdApi(MdApi) :
   """
   继承行情API即MdApi,进行底层接口对接
  """
#----------------------------------------------------------------------
def __init__(self, eventEngine) :
   super(DemoMdApi, self).__init__()

   # 事件引擎,所有数据都推送到其中,再由事件引擎进行分发
   self.__eventEngine = eventEngine

   # 请求编号,由api负责管理
   self.__reqid = 0

   # 以下变量用于实现连接和重连后的自动登陆
   self.__userid = ''
   self.__password = ''
   self.__brokerid = ''

   # 以下集合用于重连后自动订阅之前已订阅的合约,使用集合为了防止重复
   self.__setSubscribed = set()

   # 初始化.con文件的保存目录为\mdconnection,注意这个目录必须已存在,否则会报错
   self.createFtdcMdApi(os.getcwd() + '\mdconnection\')
  1. DemoMdApi类继承自MdApi类。
  2. 创建DemoMdApi的对象时,用户需要传入的参数是事件驱动引擎对象eventEngine。
  3. 每次调用API的主动函数时,需要传入一个reqid的参数,作为本次请求的唯一标识,绝大部分情况下我们不需要在意每个请求的标识情况,因此选择将该参数交给DemoMdApi对象来维护,每次调用主动函数时自动加1。
  4. 我们在DemoMdApi的对象中保存用户名、密码和经纪商编号,用于前置机连接完成后的自动登录功能,以及断线重连相关的操作。
  5. __setSubscribed对应的是一个Python集合,用于保存我们通过订阅函数订阅过的合约,在断线重连后自动进行订阅,之所以选择set而不是list是为了保证合约的唯一性,避免重复订阅(尽管重复订阅也没影响)。
  6. 在创建对象DemoMdApi对象的同时,自动调用createFtdcMdApi来初始化连接接口,选择使用当前目录下的mdconnection文件夹来保存.con通讯文件。

具体任务处理函数的实体内容(用于处理回调)

具体任务处理函数如processRspUserLogin(),其实体内容为onRspUserLogin(),其在MdApi中被定义为纯虚函数,在XXgateway.py中通过对MdApi类继承并重写该虚函数,进行具体实现,下面就阐述在经过MdApi父类继承后的子类DemoMdApi中是如何进行具体实现的。****

def onFrontConnected(self):
    """服务器连接"""
    event = Event(type_=EVENT_LOG)
    event.dict_['log'] = u'行情服务器连接成功'
    self.__eventEngine.put(event)

    # 如果用户已经填入了用户名等等,则自动尝试连接
    if self.__userid:
        req = {}
        req['UserID'] = self.__userid
        req['Password'] = self.__password
        req['BrokerID'] = self.__brokerid
        self.__reqid = self.__reqid + 1
        self.reqUserLogin(req, self.__reqid)
        
        
def onRspUserLogin(self, data, error, n, last):
    """登陆回报"""
    event = Event(type_=EVENT_LOG)

    if error['ErrorID'] == 0:
        log = u'行情服务器登陆成功'
    else:
        log = u'登陆回报,错误代码:' + unicode(error['ErrorID']) + u',' + u'错误信息:' + error['ErrorMsg'].decode('gbk')

    event.dict_['log'] = log
    self.__eventEngine.put(event)

    # 重连后自动订阅之前已经订阅过的合约
    if self.__setSubscribed:
        for instrument in self.__setSubscribed:
            self.subscribe(instrument[0], instrument[1])

def onRtnDepthMarketData(self, data):
    """行情推送"""
    # 行情推送收到后,同时触发常规行情事件,以及特定合约行情事件,用于满足不同类型的监听
    # 常规行情事件
    event1 = Event(type_=EVENT_MARKETDATA)
    event1.dict_['data'] = data
    self.__eventEngine.put(event1)
    # 特定合约行情事件
    event2 = Event(type_=(EVENT_MARKETDATA_CONTRACT + data['InstrumentID']))
    event2.dict_['data'] = data
    self.__eventEngine.put(event2)

  1. 通过回调函数收到API的数据推送后,创建不同类型的事件Event对象(来自于事件驱动引擎模块),在事件对象的数据字典dict_中保存需要具体推送的数据,然后推送到事件驱动引擎中,由其负责处理。
  2. 回调函数收到的数据中,data和error分别对应的是保存主要数据(如行情)和错误信息的字典,n是该回调函数对应的请求号(即调用主动函数时的reqid),last是一个布尔值,代表是否为该次调用的最后返回信息。
  3. 我们主要对data字典感兴趣,因此选择在事件中整体推送。
  4. 而error字典每次收到后应当立即检查是否包含错误信息(因为即使没有发生错误也会推送),若有则自动保存为一个日志事件(通过日志监控控件显示出来)。
  5. 服务器连接完成后(onFrontConnected),检查是否已经填入了用户名等登录信息,若有则自动登录(请参考后面主动函数中的示例)。
  6. 登陆完成后(onRspUserLogin),自动订阅__setSubscribed中之前已经订阅过的合约。
  7. 收到行情推送后(onRtnDepthMarketData),我们选择创建两种事件,一种是常规行情事件(通常适用于市场行情监控GUI等对所有行情推送都关注的组件),另一种是特定合约行情事件(通常适用于算法等仅关注特定合约行情的组件)。
  8. 当我们调用会有返回信息的主动函数时,需要传入本次请求的编号,此时我们先将__reqid自加1,再作为参数传入主动函数中。

主动函数

def login(self, address, userid, password, brokerid):
    """连接服务器"""
   self.__userid = userid
    self.__password = password
    self.__brokerid = brokerid
    # 注册服务器地址
   self.registerFront(address)
    # 初始化连接,成功会调用onFrontConnected
   self.init()

def subscribe(self, instrumentid, exchangeid):
    """订阅合约"""
   req = {}
    req['InstrumentID'] = instrumentid
    req['ExchangeID'] = exchangeid
    self.subscribeMarketData(req)
    instrument = (instrumentid, exchangeid)
    self.__setSubscribed.add(instrument)
  1. 该主动函数仅封装了两个功能作为示例:登录login和订阅合约subscribe。这里假设通常我们不会做登出(直接杀进程)和退订合约(不一定)之类的操作,有需求的话可以自行封装对应的函数。
  2. 对于登录函数login而言,传入参数包括服务器前置机地址address,用户名userid,密码password以及经纪商代码brokerid。函数调用后,我们先将userid,password和brokerid保存下来,然后注册服务器地址registerFront,并初始化连接init。连接完成后,onFrontConnected会被自动调用。
  3. LTS的API在订阅行情时,需要传入合约的代码以及合约所在的交易所(因为存在两个证券交易所相同代码的情况),而CTP的API在期货方面则不存在该问题,只需传入合约代码。发送订阅请求后,将该订阅请求保存在__setSubscribed集合中,使得断线重连时可以自动重新订阅。

总结

在交易程序的开发中,所有的API对接原理均大同小异,除了类CTP API以外,国内的恒生接口、FIX引擎接口等等也可以同样遵照以上的原理进行对接设计。

文章中的例子是行情接口,交易接口因为包含了更多的回调函数和主动函数,在设计上相对更为复杂,具体细节参见源代码。

最后,小编基于对源码的理解,以市场行情连接及登录为例,大致阐述流程细节,下面流程阐述是基于CTP接口对接的,"用python的交易员"是以华宝lts对接阐述的,不过都大同小异啦。当然这仅仅是一部分流程,对于更多的细节内容,还得通过源码阅读并总结理解。