22、交易所接口适配:统一接口抽象层,不同交易所差异处理(Binance/Deribit/CME),协议版本管理
做市商系统最头疼的是什么?不是策略逻辑,不是风控模型——而是对接交易所。
我见过太多团队,花三个月写策略,花一年修接口。为什么?因为每个交易所的API都像另一个星球的语言。Binance用REST+WebSocket,Deribit偏要搞个自定义的JSON-RPC,CME更是直接甩给你一个FIX协议。嗯,这还没算上它们各自的限频规则、签名算法、错误码体系。
所以这一章,我们来聊聊怎么用统一接口抽象层,把这些乱七八糟的差异统统封装掉。说白了,就是让上层策略代码永远只跟一套接口打交道,底层换交易所就像换插座一样简单。
为什么需要统一抽象层?
你想想看,一个做市商系统通常要同时连3-5个交易所。如果每个交易所都单独写一套对接逻辑,那代码里会充斥着if-else判断,维护成本直线飙升。
我在项目中遇到过最惨的一次:Binance升级了WebSocket推送格式,我们花了三天改代码。结果改完才发现,Deribit那边也悄悄改了签名算法。那周我基本没睡过整觉。
统一抽象层的核心价值就三点:
- 隔离变化:交易所API升级,只改适配层,策略代码纹丝不动
- 复用逻辑:重连机制、心跳检测、数据校验,一套代码跑遍所有交易所
- 快速接入:新交易所上线,写个适配器就行,不用动核心系统
核心原则:上层策略永远只依赖抽象接口,不依赖具体交易所实现。这就是依赖倒置原则在量化系统里的实战应用。
抽象接口设计:从订单到行情
我们先定义一套通用的接口。我个人习惯把它拆成三大块:行情接口、交易接口、账户接口。
来看一个简化版的抽象基类:
from abc import ABC, abstractmethod
from typing import Dict, List, Optional
from datetime import datetime
class ExchangeAdapter(ABC):
"""交易所适配器抽象基类"""
@abstractmethod
async def get_ticker(self, symbol: str) -> Dict:
"""获取最新行情"""
pass
@abstractmethod
async def get_orderbook(self, symbol: str, depth: int = 10) -> Dict:
"""获取订单簿"""
pass
@abstractmethod
async def place_order(self, order: Dict) -> Dict:
"""下单"""
pass
@abstractmethod
async def cancel_order(self, order_id: str, symbol: str) -> bool:
"""撤单"""
pass
@abstractmethod
async def get_balance(self, currency: str) -> Dict:
"""查询账户余额"""
pass
@abstractmethod
async def get_position(self, symbol: str) -> Dict:
"""查询持仓"""
pass
这个基类定义了所有交易所必须实现的方法。但你会发现,不同交易所的返回格式千差万别。比如Binance的订单簿是{bids: [[price, qty], ...], asks: [[price, qty], ...]},而Deribit的格式完全不同。
所以我们需要一个内部统一数据模型:
@dataclass
class OrderBookLevel:
price: float
quantity: float
exchange: str
timestamp: datetime
@dataclass
class OrderBook:
symbol: str
bids: List[OrderBookLevel]
asks: List[OrderBookLevel]
exchange: str
timestamp: datetime
每个适配器在拿到交易所原始数据后,先转换成这个统一模型,再往上抛。这样策略层永远只认OrderBook,不用管底层是Binance还是Deribit。
我的习惯:在适配器内部维护一个_data_mapper字典,专门做字段映射。比如Binance的"price"映射到我们的"price",Deribit的"instrument_name"映射到"symbol"。这样改映射比改代码快得多。
三大交易所差异处理
好了,理论说完了,我们来看看实战中怎么处理具体差异。我挑三个典型交易所:Binance、Deribit、CME。它们代表了三种完全不同的API风格。
Binance:REST + WebSocket 流
Binance的API设计得很规范,文档也清晰。但坑也不少。
- 签名算法:HMAC-SHA256,需要把参数按字典序排序后拼接签名
- 限频规则:按IP和API Key双重限频,WebSocket每5秒只能发一次订阅请求
- WebSocket重连:每24小时强制断开一次,必须实现自动重连
我曾经踩过一个坑:Binance的WebSocket在断连后,不会自动恢复订阅。你得手动重新订阅所有symbol。所以我在适配器里加了个_resubscribe_all()方法,每次重连后自动调用。
class BinanceAdapter(ExchangeAdapter):
def __init__(self, api_key: str, api_secret: str):
self._api_key = api_key
self._api_secret = api_secret
self._ws_connections = []
self._subscribed_symbols = set()
async def _sign_request(self, params: Dict) -> str:
"""Binance签名逻辑"""
query_string = '&'.join([f"{k}={v}" for k, v in sorted(params.items())])
signature = hmac.new(
self._api_secret.encode(),
query_string.encode(),
hashlib.sha256
).hexdigest()
return signature
async def _on_ws_disconnect(self):
"""WebSocket断连后自动恢复"""
await self._reconnect_ws()
for symbol in self._subscribed_symbols:
await self._subscribe_symbol(symbol)
Deribit:JSON-RPC 风格
Deribit的API跟Binance完全不同。它用的是JSON-RPC协议,所有请求都发到一个端点,通过method字段区分操作。
- 认证方式:先获取access_token,后续请求带token
- 请求格式:{"jsonrpc": "2.0", "id": 1, "method": "xxx", "params": {...}}
- 订阅机制:WebSocket订阅需要先认证,然后发subscription请求
Deribit有个很坑的地方:它的订单ID是字符串,而且包含特殊字符。我遇到过订单ID里带斜杠的情况,导致数据库存储时报错。所以我在适配器里统一做了转义处理。
class DeribitAdapter(ExchangeAdapter):
def __init__(self, client_id: str, client_secret: str):
self._client_id = client_id
self._client_secret = client_secret
self._access_token = None
self._token_expiry = 0
async def _get_access_token(self) -> str:
"""获取Deribit的access_token"""
# Deribit的token有效期只有1小时,需要定时刷新
if time.time() > self._token_expiry - 60:
# 刷新token逻辑
pass
return self._access_token
async def place_order(self, order: Dict) -> Dict:
"""Deribit的下单请求是JSON-RPC格式"""
params = {
"instrument_name": order['symbol'],
"amount": order['quantity'],
"type": order['order_type'],
"price": order.get('price')
}
return await self._call_rpc("private/place_order", params)
CME:FIX 协议
CME是传统交易所,用的是FIX协议。这玩意儿跟REST完全是两个世界。
- 协议格式:键值对用SOH字符分隔,比如"35=D|55=ETH|54=1|..."
- 会话管理:需要维护心跳、序列号、重传机制
- 订单确认:所有操作都是异步的,通过ExecutionReport消息确认
FIX协议最让人头疼的是序列号管理。如果断连后序列号对不上,交易所会要求你重传消息。我早期写CME适配器时,就因为序列号没处理好,导致订单状态不同步,差点造成重大损失。
class CMEAdapter(ExchangeAdapter):
def __init__(self, fix_config: Dict):
self._sender_comp_id = fix_config['sender_comp_id']
self._target_comp_id = fix_config['target_comp_id']
self._msg_seq_num = 1
self._pending_orders = {}
async def _send_fix_message(self, msg_type: str, fields: Dict) -> bytes:
"""构造FIX消息"""
# FIX消息格式:8=FIX.4.4|9=长度|35=消息类型|49=发送方|56=接收方|34=序列号|52=时间戳|...|10=校验和
msg = f"8=FIX.4.4|9=0|35={msg_type}|49={self._sender_comp_id}|"
msg += f"56={self._target_comp_id}|34={self._msg_seq_num}|"
msg += f"52={datetime.utcnow().strftime('%Y%m%d-%H:%M:%S')}|"
for tag, value in fields.items():
msg += f"{tag}={value}|"
# 计算长度和校验和
# ... 省略具体实现
self._msg_seq_num += 1
return msg.encode()
注意:FIX协议的序列号必须严格递增,不能跳号。如果断连重连,需要先发送Logon消息并带上上次的序列号,交易所会告诉你从哪个序列号开始重传。
协议版本管理
交易所API会升级,这是常态。Binance一年能改好几次API版本。怎么管理?
我的做法是:版本号 + 适配器工厂。
每个适配器内部维护一个版本号,比如BinanceAdapterV1、BinanceAdapterV2。然后通过工厂方法根据配置选择版本:
class ExchangeAdapterFactory:
_adapters = {
'binance': {
'v1': BinanceAdapterV1,
'v2': BinanceAdapterV2,
},
'deribit': {
'v1': DeribitAdapterV1,
'v2': DeribitAdapterV2,
},
'cme': {
'v1': CMEAdapterV1,
}
}
@classmethod
def create_adapter(cls, exchange: str, version: str, config: Dict) -> ExchangeAdapter:
adapter_class = cls._adapters.get(exchange, {}).get(version)
if not adapter_class:
raise ValueError(f"Unsupported exchange {exchange} version {version}")
return adapter_class(config)
这样,当Binance升级到v3时,我们只需要新增一个BinanceAdapterV3,然后在配置里切换版本号就行。旧版本还能继续用,不影响正在运行的策略。
避坑指南:我曾经在版本切换时,忘记更新WebSocket订阅地址。结果新版本用了新的ws端点,旧代码还在连老的。所以版本管理一定要把REST和WebSocket的端点地址也纳入版本控制。
统一接口层架构图
下面这张图展示了整个适配层的架构。你可以看到,策略层只跟抽象接口打交道,具体实现由适配器工厂根据配置动态选择。
总结
交易所接口适配,说白了就是做一层隔离。让上层策略永远不直接依赖具体交易所的实现细节。
核心要点就三个:
- 统一抽象接口:定义一套通用的行情、交易、账户接口,所有适配器必须实现
- 差异封装:每个交易所的签名算法、限频规则、协议格式,都在适配器内部消化
- 版本管理:用适配器工厂+版本号,平滑应对API升级
我曾经见过一个团队,因为没做接口抽象,换交易所时改了200多个文件。而我们的系统,换交易所只需要改一行配置。这就是架构的力量。
嗯,这一章就到这里。代码示例都在GitHub上,你可以直接拿去用。记住,好的架构不是一蹴而就的,是在一次次踩坑中打磨出来的。