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版本。怎么管理?

我的做法是:版本号 + 适配器工厂

每个适配器内部维护一个版本号,比如BinanceAdapterV1BinanceAdapterV2。然后通过工厂方法根据配置选择版本:

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的端点地址也纳入版本控制。

统一接口层架构图

下面这张图展示了整个适配层的架构。你可以看到,策略层只跟抽象接口打交道,具体实现由适配器工厂根据配置动态选择。

策略层(Strategy) 统一抽象接口层 ExchangeAdapter(行情/交易/账户) 适配器工厂(Factory) BinanceAdapter REST + WebSocket DeribitAdapter JSON-RPC CMEAdapter FIX协议 Binance Deribit CME

总结

交易所接口适配,说白了就是做一层隔离。让上层策略永远不直接依赖具体交易所的实现细节。

核心要点就三个:

  • 统一抽象接口:定义一套通用的行情、交易、账户接口,所有适配器必须实现
  • 差异封装:每个交易所的签名算法、限频规则、协议格式,都在适配器内部消化
  • 版本管理:用适配器工厂+版本号,平滑应对API升级

我曾经见过一个团队,因为没做接口抽象,换交易所时改了200多个文件。而我们的系统,换交易所只需要改一行配置。这就是架构的力量。

嗯,这一章就到这里。代码示例都在GitHub上,你可以直接拿去用。记住,好的架构不是一蹴而就的,是在一次次踩坑中打磨出来的。