第二章 数据源与接入:主流交易所Tick数据格式解析(CTP、Binance、WebSocket)

做量化交易,第一道坎就是数据接入。我见过太多人把精力全花在策略上,结果数据源没选对,回测跑得飞起,实盘一接就崩。说白了,Tick数据是交易的「原子」——每一笔成交、每一次报价变动,都是市场最真实的呼吸。

今天咱们就掰开揉碎,聊聊CTP、Binance和WebSocket这三种主流接入方式。嗯,我会把我在项目中踩过的坑、总结的经验,一股脑倒出来。

2.1 CTP:国内期货的「硬通货」

CTP(综合交易平台)是上期技术开发的,国内期货市场几乎人手一套。它的Tick数据格式非常固定,我当年第一次接触时,觉得这玩意儿太「硬核」了——全是二进制流,连个换行符都找不到。

2.1.1 数据结构

CTP的Tick数据,核心是CThostFtdcDepthMarketDataField结构体。我挑几个关键字段说说:

字段名 类型 说明
InstrumentID char[31] 合约代码,比如"rb2410"
LastPrice double 最新成交价
Volume int 当日累计成交量
Turnover double 当日累计成交额
BidPrice1 double 买一价
AskPrice1 double 卖一价
BidVolume1 int 买一量
AskVolume1 int 卖一量
UpdateTime char[9] 行情时间,格式"HH:MM:SS"
UpdateMillisec int 毫秒数

注意,CTP的Tick是「快照」模式——每次推送都包含当前所有档位的买卖盘。我习惯把它叫做「全量快照」,因为每次数据包都带着完整的十档行情。

核心要点:CTP的Tick频率取决于行情变化。如果市场平静,可能几秒才来一笔;如果剧烈波动,一秒钟能来几十笔。你想想看,这对处理速度的要求有多高?

2.1.2 接入代码示例

下面是我常用的CTP行情订阅代码片段。注意,这里用的是C++,因为CTP的API本身就是C++风格:

// 创建行情回调类
class CMdSpi : public CThostFtdcMdSpi {
public:
    // 收到深度行情时的回调
    virtual void OnRtnDepthMarketData(
        CThostFtdcDepthMarketDataField *pDepthMarketData) {
        
        // 提取关键字段
        std::string instrument = pDepthMarketData->InstrumentID;
        double lastPrice = pDepthMarketData->LastPrice;
        int volume = pDepthMarketData->Volume;
        
        // 这里可以写入队列或直接处理
        printf("合约:%s 最新价:%.2f 成交量:%d\n", 
               instrument.c_str(), lastPrice, volume);
    }
};

// 初始化并订阅
CMdSpi* pMdSpi = new CMdSpi();
pMdUserApi->RegisterSpi(pMdSpi);
pMdUserApi->SubscribeMarketData("rb2410", 1);

避坑指南:我曾经在CTP的回调函数里直接做数据库写入,结果行情一快,程序直接崩了。后来学乖了——回调里只做数据入队列,另起线程处理。记住,回调函数不是给你干重活的。

2.2 Binance:加密货币的「WebSocket流」

Binance的Tick数据走的是WebSocket协议,和CTP完全是两个世界。CTP是二进制、全量快照;Binance是JSON、增量更新。我第一次从CTP切到Binance时,觉得这数据格式也太「亲民」了——直接能看懂。

2.2.1 数据格式

Binance的深度流(depth stream)有两种模式:

  • partial book depth:每100ms或1000ms推送一次全量快照
  • diff depth:实时推送增量变化

我个人更推荐用diff depth,因为它延迟更低。来看一个典型的diff depth消息:

{
  "e": "depthUpdate",      // 事件类型
  "E": 1672531200000,      // 事件时间(毫秒时间戳)
  "s": "BTCUSDT",          // 交易对
  "U": 100,                // 第一更新ID
  "u": 200,                // 最后更新ID
  "b": [                   // 买单变化
    ["28000.00", "1.5"],   // [价格, 数量]
    ["27999.00", "0.8"]
  ],
  "a": [                   // 卖单变化
    ["28001.00", "2.0"],
    ["28002.00", "1.2"]
  ]
}

注意,Binance的增量更新是「替换」逻辑——如果某个价格档位的数量变为0,就表示该档位被移除。我刚开始做的时候没注意这点,结果深度数据越积越多,内存直接爆了。

2.2.2 WebSocket接入示例

用Python接入Binance的WebSocket,我一般用websockets库,轻量又好用:

import asyncio
import websockets
import json

async def subscribe_binance_depth(symbol="btcusdt"):
    # 构建订阅URL
    stream_name = f"{symbol}@depth@100ms"
    url = f"wss://stream.binance.com:9443/ws/{stream_name}"
    
    async with websockets.connect(url) as ws:
        # 订阅消息(其实URL已经指定了,但可以再发一次确认)
        subscribe_msg = {
            "method": "SUBSCRIBE",
            "params": [stream_name],
            "id": 1
        }
        await ws.send(json.dumps(subscribe_msg))
        
        # 持续接收数据
        while True:
            raw_data = await ws.recv()
            tick_data = json.loads(raw_data)
            
            # 提取买卖盘变化
            bids = tick_data.get("b", [])
            asks = tick_data.get("a", [])
            
            # 这里可以更新本地的OrderBook
            print(f"收到深度更新: {len(bids)}档买单, {len(asks)}档卖单")

# 运行
asyncio.run(subscribe_binance_depth())

注意:Binance的WebSocket有连接数限制。个人用户最多同时连接200个流。我曾经为了监控50个交易对,开了50个连接,结果被限流了。后来改用「组合流」(combined streams),一个连接订阅多个交易对,问题就解决了。

2.3 WebSocket:通用协议与实战要点

不管是Binance还是其他交易所,WebSocket都是主流。但WebSocket本身只是个传输协议,真正重要的是「心跳维持」和「数据重连」机制。

2.3.1 心跳机制

交易所一般会定期发送ping帧,客户端必须在规定时间内回复pong。如果超时,连接会被断开。我见过最坑的是某小交易所,心跳间隔只有10秒,网络稍微波动就断连。

下面是我常用的心跳处理模板:

import asyncio
import websockets

async def keep_alive(ws, interval=20):
    """每20秒发送一次ping"""
    while True:
        try:
            await ws.ping()
            await asyncio.sleep(interval)
        except websockets.ConnectionClosed:
            print("连接已关闭,停止心跳")
            break

async def main():
    async with websockets.connect("wss://example.com/ws") as ws:
        # 启动心跳任务
        heartbeat_task = asyncio.create_task(keep_alive(ws))
        
        # 主数据接收循环
        async for message in ws:
            # 处理数据
            pass
        
        # 等待心跳任务结束
        await heartbeat_task

2.3.2 断线重连策略

做量化最怕什么?行情来了,连接断了。我总结了一套「指数退避+随机抖动」的重连策略:

async def connect_with_retry(url, max_retries=10):
    retry_delay = 1  # 初始延迟1秒
    
    for attempt in range(max_retries):
        try:
            async with websockets.connect(url) as ws:
                print(f"连接成功,尝试次数: {attempt+1}")
                return ws
        except (websockets.ConnectionClosed, 
                websockets.WebSocketException) as e:
            # 指数退避 + 随机抖动
            import random
            delay = retry_delay + random.uniform(0, 0.5)
            print(f"连接失败,{delay:.2f}秒后重试...")
            await asyncio.sleep(delay)
            retry_delay = min(retry_delay * 2, 60)  # 最大60秒
    
    raise Exception("重连失败,请检查网络")

个人经验:我曾经在重连时忘记重新订阅行情,结果程序「假活」——连接是通的,但数据一条都没有。后来我加了个「订阅确认」机制:每次重连后,必须收到交易所的订阅成功回复,才认为恢复完成。

2.4 三种接入方式对比

做个表格,一目了然:

维度 CTP Binance WebSocket 通用WebSocket
协议 TCP + 二进制 WebSocket + JSON WebSocket
数据模式 全量快照 增量更新 取决于交易所
延迟 低(毫秒级) 低(毫秒级) 中低
解析难度 高(需二进制解析) 低(JSON直接解析)
适用场景 国内期货 加密货币 多交易所统一接入

2.5 知识体系总览

下面这张图,是我梳理的本章知识结构。你可以把它当成一张「地图」,随时回来对照:

Tick数据接入知识体系 CTP(国内期货) 二进制全量快照 C++ API回调 队列异步处理 Binance(加密货币) WebSocket JSON流 增量深度更新 组合流订阅 通用WebSocket 心跳维持机制 指数退避重连 订阅确认机制 核心原则:低延迟 + 高可靠 + 易维护 选择接入方式时,先问自己:交易什么品种?延迟要求多高?团队技术栈是什么?

嗯,这张图把三种接入方式的核心要点都串起来了。CTP偏硬核,Binance偏灵活,通用WebSocket则是「万金油」。你想想看,如果能把这三套都吃透,市面上90%的交易所数据你都能搞定。

最后说一句:数据接入是量化系统的「地基」。地基不稳,上面盖多高的楼都白搭。我见过太多团队,策略模型跑得飞起,结果因为数据延迟高了10毫秒,实盘直接亏钱。所以,别嫌这一步麻烦,值得花时间打磨。

专注资料整理