欧易 · API 进阶
欧易 WebSocket 的保活心跳是一条内容为 ping 的字符串
脚本连上 30 秒就断,对应欧易 API 文档写明的两种情形:连接成功后 30 秒内没有订阅任何频道;或者订阅了,但服务器 30 秒内没有向你推送数据。分辨办法是看订阅请求的返回:连上后 30 秒内没收到 event 为 subscribe 的订阅成功消息,落在前一种;收到了,之后 30 秒里服务器没有数据推过来,落在后一种。订阅失败时,返回消息的 event 是 error;只发不看返回,订阅没成功脚本也不知道,30 秒一到连接就被断开。
订阅成功后没有推送的那一种,要靠心跳来保住,也就是隔一段时间发一条小消息,证明这条连接还通着。欧易要的心跳是一条内容为 ping 的文本消息,服务器回一条 pong。Python 的 websockets 库在后台自动发的 Ping 帧是另一种消息。直接用 WebSocket 库收发消息的脚本,如果从没发过这条字符串,就把这一步补上;用 ccxt 这类封装库连接的,保活怎么处理看库自己的文档。
没有新消息时要赶在 30 秒之前自己发一条字符串 ping
文档「连接」一节给的保活做法是三步:
- 每次接收到消息后,设置一个定时器,定时 N 秒,N 小于 30;
- 定时器被触发,也就是 N 秒内没有收到新消息,发送字符串
ping; - 等一个字符串
pong作为回应,N 秒内没收到,就报错或者重新连接。
第 1 步里,收到的任何一条消息都会让定时器重新计时。推送密的频道上定时器一直在重置,ping 一条也不用发;安静满 N 秒,才轮到第 2 步。对 N 的要求只有「小于 30」,取多少由你定。
ping 就是四个字母的纯文本,不带订阅请求那样的 JSON 结构,回来的 pong 也一样。脚本如果对每条收到的消息都直接做 JSON 解析,要在解析之前把 pong 挑出来。
这三步用 Python 标准库的 asyncio 写出来是下面这样。recv、send 换成你所用 WebSocket 库里收一条、发一条文本消息的协程函数,handle 是你自己处理行情或订单消息的函数:
import asyncio
N = 20 # 自己取的值;官方只要求 N 小于 30
async def keep_alive(recv, send, handle):
# recv / send:所用 WebSocket 库收一条、发一条文本消息的协程函数
while True:
try:
msg = await asyncio.wait_for(recv(), N) # 收到任何消息,计时重新开始
except asyncio.TimeoutError:
await send("ping") # N 秒内没有新消息:发字符串 ping
msg = await asyncio.wait_for(recv(), N) # 再等 N 秒;没有任何消息(含 pong)才抛 TimeoutError
if msg != "pong":
handle(msg) # 其余消息交给自己的处理函数
第二次等待的 N 秒里要是仍然没有任何消息,asyncio.TimeoutError 会从函数里抛出去,对应第 3 步的报错或重新连接,由调用它的那一层来接。发完 ping 之后先到的如果是一条行情,说明连接还通着,计时重新开始,稍后到的 pong 在最后那个判断里被跳过。
用 Python websockets 库时字符串 ping 要用 send 另外发
WebSocket 协议自己也有 ping。RFC 6455 里,文本和二进制消息走数据帧,关闭、Ping、Pong 属于控制帧,用来传递连接本身的状态。Ping 的操作码是 0x9;一端收到它必须回一个 Pong,除非已经收到过关闭帧;它可以用来保活,也可以用来确认对端还有响应。
Python 的 websockets 库默认就开着这种保活:每 20 秒发一个 Ping 帧,20 秒内等不到 Pong 就认定连接已断并把它终止,两个时间由 connect() 的 ping_interval、ping_timeout 参数调。它的文档接着提到,有些服务器要求客户端定期发特定内容的保活消息,通常要的是文本帧而不是 Ping 帧,这种消息得用 send 发;库把它归为应用层的保活,由使用者自己实现。
欧易「连接」一节的字符串 ping 属于后一类。库的保活在后台发控制帧,不会替你发这条文本;照那三步做,第 2 步要用库里发文本消息的方法把 ping 发出去,在 websockets 里是 send。
欧易另有一个 SBE(简单二进制编码)行情地址 /ws/v5/public-sbe,它靠控制帧保活:服务端发操作码为 9 的 Ping 帧,客户端收到后要尽快回操作码 10 的 Pong 帧并复制它的 payload,服务器 60 秒内没收到就断开连接。连这个地址的脚本按它自己的规则写。
断线重连时要守住建连频率、订阅次数和登录时效
新建连接的上限是每秒 3 次,基于 IP 计。断线后不加等待就循环重连,一秒里发出的建连请求可能超过这个数;同一个 IP 上跑着几个脚本,建连次数算在一起。两次重试之间要留间隔,连续失败时把间隔逐次拉长,这种做法叫退避。间隔取多少由自己定。
订阅、取消订阅和登录三种请求合在一起,每个连接每小时 480 次,平均每分钟 8 次。重连后登录一次、把频道订回来,用不了几次;运行中反复退订再订阅,次数才涨得快:每 5 秒退订一个交易对、再订另一个,一分钟是 12 × 2 = 24 次请求,一小时 24 × 60 = 1,440 次,是上限的三倍。一条订阅请求可以带一个或多个频道,多个频道的总长度不超过 64 KB,不必每个频道单发一条请求。
REST 接口的请求次数限制按接口分别定,不在这 480 次里:重连后查持仓用的 GET /api/v5/account/positions 是 10 次/2s,按 User ID 计。
私有频道要先登录。登录请求里的 timestamp 是 Unix 时间,单位是秒,请求在这个时间戳之后 30 秒失效。重连代码里要是留着断线前算好的 timestamp 和 sign,隔了 30 秒再拿去登录,这条请求已经失效。每次重连都重新取当前时间、重新算签名:timestamp、'GET'、'/users/self/verify' 三段拼成字符串,用 SecretKey 做 HMAC SHA256,再做 Base64 编码。本机时钟和服务器有偏差时,欧易推荐用 REST API 查服务器时间再设时间戳;获取系统时间的 GET /api/v5/public/time 返回的 ts 是毫秒数,要换成秒再用。登录失败时返回一条 event 为 error 的消息,示例里的 msg Login failed. 对应的 code 是 60009。
| 项目 | 上限或时效 | 按什么计 |
|---|---|---|
| 建立 WebSocket 连接 | 3 次/秒 | 基于 IP |
| 订阅、取消订阅、登录请求合计 | 480 次/小时 | 每个连接 |
| 订阅同一个受限私有频道的连接数 | 30 个 | 每个子账户、每个频道 |
| 登录请求 | 时间戳之后 30 秒失效 | 请求里的 timestamp |
| 订阅多个频道 | 总长度不超过 64 KB | 多个频道合计 |
重连用的地址和首次连接相同:订阅公有频道连公有服务的地址,订阅私有频道连私有服务的地址,实盘分别是 wss://ws.okx.com/ws/v5/public 和 wss://ws.okx.com/ws/v5/private,另有业务频道的地址 wss://ws.okx.com/ws/v5/business。模拟盘的主机名换成 wspap.okx.com,路径相同,模拟盘请求的 header 里还要加 x-simulated-trading: 1。
不管是用 ccxt.pro 订阅欧易行情还是接订单推送,新连接订阅好以后、策略继续下单之前,都用 REST 查一次当前挂单和持仓:断线期间本地记的状态可能已经过时,没查清就补发订单容易重复下单。
升级前 60 秒推送的 64008 通知与提前建新连接
欧易升级 WebSocket 服务会造成断线,断线之前有一条通知:升级前 60 秒,连接上会收到一条 event 为 notice 的消息,带着这条连接的 connId,示例里的 msg The connection will soon be closed for a service upgrade. Please reconnect. 对应的 code 是 64008。这条通知目前支持 WebSocket 公共频道(/ws/v5/public)和私有频道(/ws/v5/private)。
收到它,可以趁这段时间建一条新连接,避开断线的影响。一种写法是在消息处理处加一个分支:event 等于 notice 时另起一条连接,登录(时间戳和签名重新算)、订阅,新连接收到订阅成功返回后再关掉旧的。订的是下一节那几个受限频道时,新旧两条连接并存期间这些频道的连接数按两条算,要等新连接收到 channel-conn-count、确认后面没有跟来 channel-conn-count-error,再关旧的。并存的这段时间里,同一条推送可能收到两遍,处理函数重复处理也不能出错。
没有这条通知的断线也有:出现网络问题时,系统同样会自动断开连接。保活超时那条分支要一直留着,通知只让其中一种断线能提前知道。
欧易订单频道和账户频道最多能同时开多少个 WebSocket 连接?
一个子账户下,订阅同一个频道的 WebSocket 连接最多 30 个,受这条限制的频道有六个:
- 订单频道
- 账户频道
- 持仓频道
- 账户余额和持仓频道
- 爆仓风险预警推送频道
- 账户greeks频道
连接数按频道分别计。同一条连接里用不同参数订同一个频道,例如 {"channel": "orders", "instType": "ANY"} 和 {"channel": "orders", "instType": "SWAP"},只算一次;订单频道和账户频道是两个频道,计数不累加。每条连接有一个唯一的 connId,登录和订阅的返回里都带着它。
新连接订上这类频道时,会收到一条 event 为 channel-conn-count 的消息,里面的 connCount 是这个频道当前的连接数。超过 30 个时,一般是最新订阅的那条连接被拒:它先收到和平时一样的订阅成功消息,随后收到 channel-conn-count-error,表示平台终止了这条连接的订阅。异常场景下,平台会终止现有连接对这个频道的订阅。
订这六个频道的脚本因此不能把订阅成功消息当成终点。收到 channel-conn-count-error 之后,连接数没降下来就反复重订,每一次都计入订阅请求的小时上限。这时该去查同一个子账户下还有哪些程序订着这个频道,比如没退出的旧进程、升级切换时没关掉的旧连接。把用不着的关掉,连接数才降得下来。通过 WebSocket 做的下单、改单和撤单不受这条连接数限制影响。