第二章 数据源与接入:主流交易所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 知识体系总览
下面这张图,是我梳理的本章知识结构。你可以把它当成一张「地图」,随时回来对照:
嗯,这张图把三种接入方式的核心要点都串起来了。CTP偏硬核,Binance偏灵活,通用WebSocket则是「万金油」。你想想看,如果能把这三套都吃透,市面上90%的交易所数据你都能搞定。
最后说一句:数据接入是量化系统的「地基」。地基不稳,上面盖多高的楼都白搭。我见过太多团队,策略模型跑得飞起,结果因为数据延迟高了10毫秒,实盘直接亏钱。所以,别嫌这一步麻烦,值得花时间打磨。