3、原始Tick数据结构解析:交易所原始报文格式、字段含义
做量化交易的朋友都知道,Tick数据是期货市场的"原子"数据。说白了,每一笔成交、每一次报价变动,都记录在Tick里。我刚开始接触这个领域时,第一反应就是——这玩意儿到底长什么样?
今天我们就来拆解一下,交易所发出来的原始报文,到底包含了哪些信息。
3.1 交易所原始报文长什么样?
国内四大期货交易所(上期所、郑商所、大商所、中金所)的Tick数据格式大同小异。我拿最常见的CTP接口报文举例,它通常是一个结构体,包含几十个字段。
但说实话,我们做数据清洗时,真正关心的核心字段其实就十几个。其他的要么是冗余信息,要么是内部标识。
来看一个典型的Tick报文结构(简化版):
struct CThostFtdcDepthMarketDataField {
char TradingDay[9]; // 交易日
char InstrumentID[31]; // 合约代码
char ExchangeID[9]; // 交易所代码
double LastPrice; // 最新价
int Volume; // 成交量(总量)
double Turnover; // 成交额(总量)
double OpenPrice; // 今开盘
double HighestPrice; // 最高价
double LowestPrice; // 最低价
double ClosePrice; // 昨收盘
double BidPrice1; // 买一价
int BidVolume1; // 买一量
double AskPrice1; // 卖一价
int AskVolume1; // 卖一量
double UpperLimitPrice; // 涨停价
double LowerLimitPrice; // 跌停价
char UpdateTime[9]; // 行情时间(HH:mm:ss)
int UpdateMillisec; // 毫秒数
};
嗯,这里要注意——Volume和Turnover是累计值,不是单笔的。我见过不少新手直接拿这个字段做计算,结果数据全错了。
3.2 核心字段深度解析
我们挑几个最重要的字段,一个一个说清楚。
3.2.1 LastPrice(最新价)
这个字段最直观,就是当前最后一笔成交的价格。但有个坑——它不一定等于BidPrice1或AskPrice1。为什么?因为成交价是买卖双方博弈的结果,而Bid/Ask只是挂单的价格。
我在项目中遇到过,有些策略直接拿LastPrice做入场判断,结果滑点大得离谱。其实你应该看的是Bid/Ask的价差。
3.2.2 Volume(成交量)
这是从当天开盘到当前时刻的累计成交量。单位是"手",不是"张"。
举个例子:
- 09:00:00 开盘时 Volume = 0
- 09:00:01 成交了10手,Volume = 10
- 09:00:02 又成交了5手,Volume = 15
所以,如果你想算单笔成交量,必须用当前Tick的Volume减去上一个Tick的Volume。
3.2.3 Turnover(成交额)
Turnover是累计成交金额,单位是元。它的计算方式是:
Turnover = Σ(每笔成交价 × 每笔成交量 × 合约乘数)
注意,不同品种的合约乘数不一样。比如螺纹钢是10吨/手,黄金是1000克/手。这个乘数在计算Turnover时已经包含进去了,你不需要再乘一次。
我曾经犯过一个低级错误——把Turnover当成"价格×成交量"直接算,结果对不上交易所的数据。后来查了半天才发现,人家已经算好了。
3.2.4 Bid/Ask(买卖盘口)
BidPrice1和AskPrice1,就是买一价和卖一价。对应的BidVolume1和AskVolume1,是买一量和卖一量。
这里有个细节:BidPrice1一定小于AskPrice1。如果出现BidPrice1 ≥ AskPrice1,那说明数据有问题,或者行情已经熔断了。
我一般会用这个关系做数据校验:
if bid_price1 >= ask_price1:
# 标记为异常数据
flag = 'INVALID_SPREAD'
3.3 时间字段的坑
UpdateTime是"HH:mm:ss"格式,UpdateMillisec是毫秒数。但这里有个大坑——这两个字段组合起来,不一定能精确到每一笔成交。
为什么?因为交易所的Tick数据是"快照"式的,不是"逐笔"式的。同一毫秒内可能有多笔成交,但只记录一个快照。
我建议你:
- 把UpdateTime和UpdateMillisec合并成一个时间戳:
HH:mm:ss.fff - 如果同一毫秒出现多条数据,按Volume的变化量来区分
- 实在分不清的,就保留原始顺序
3.4 一张图看懂Tick数据结构
下面这张图,是我自己画的数据流转逻辑。你看完应该就明白了:
3.5 实战:如何解析一条Tick数据
假设我们从CTP接口收到了一条原始数据,用Python解析一下:
import struct
from datetime import datetime
# 模拟一条原始Tick数据(二进制格式)
raw_data = {
'TradingDay': '20240115',
'InstrumentID': 'rb2405',
'ExchangeID': 'SHFE',
'LastPrice': 3985.0,
'Volume': 1256789,
'Turnover': 50012345678.0,
'BidPrice1': 3984.0,
'BidVolume1': 120,
'AskPrice1': 3986.0,
'AskVolume1': 85,
'UpdateTime': '14:30:25',
'UpdateMillisec': 350
}
# 解析成我们需要的格式
def parse_tick(raw):
tick = {
'symbol': raw['InstrumentID'],
'exchange': raw['ExchangeID'],
'datetime': f"{raw['TradingDay']} {raw['UpdateTime']}.{raw['UpdateMillisec']:03d}",
'last_price': raw['LastPrice'],
'volume': raw['Volume'],
'turnover': raw['Turnover'],
'bid_price': raw['BidPrice1'],
'bid_volume': raw['BidVolume1'],
'ask_price': raw['AskPrice1'],
'ask_volume': raw['AskVolume1']
}
return tick
# 调用
parsed = parse_tick(raw_data)
print(parsed)
输出结果:
{
'symbol': 'rb2405',
'exchange': 'SHFE',
'datetime': '20240115 14:30:25.350',
'last_price': 3985.0,
'volume': 1256789,
'turnover': 50012345678.0,
'bid_price': 3984.0,
'bid_volume': 120,
'ask_price': 3986.0,
'ask_volume': 85
}
:03d补零。
3.6 字段之间的逻辑关系
这几个核心字段不是孤立的,它们之间有内在联系。我总结了几条校验规则:
| 校验规则 | 说明 | 异常处理 |
|---|---|---|
| BidPrice1 < AskPrice1 | 买一价必须小于卖一价 | 标记为无效数据,丢弃或修复 |
| Volume ≥ 0 且递增 | 成交量不能为负,且只能增加 | 若减少,检查是否换交易日 |
| Turnover ≥ 0 且递增 | 成交额逻辑同Volume | 同上 |
| LastPrice 在涨跌停之间 | 不能超出UpperLimit/LowerLimit | 超出则标记为异常 |
| 时间戳单调递增 | 后一条Tick时间 ≥ 前一条 | 乱序则重新排序 |
这些规则看起来简单,但实际跑数据时,每一条都可能出问题。我记得有一次,某个交易所的行情网关出了bug,连续发了3秒的BidPrice1都大于AskPrice1。要不是有校验规则,这些脏数据就直接进数据库了。
3.7 小结
原始Tick数据的结构,说白了就是"时间 + 价格 + 量 + 盘口"四个维度。你只要把每个字段的含义搞清楚,把常见的坑避开,解析起来并不复杂。
我个人习惯是,在解析阶段就把数据清洗的逻辑嵌进去——比如校验Bid/Ask关系、检查Volume是否递增、统一时间格式。这样后面存储和分析时,能省掉很多麻烦。
嗯,今天就先聊到这儿。下一节我们会讲如何把这些解析好的数据,高效地存到数据库里。