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。

重要提醒: 有些交易所的Volume会在夜盘开盘时重置,有些不会。我建议你写清洗脚本时,先检查一下交易日切换时的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数据结构

下面这张图,是我自己画的数据流转逻辑。你看完应该就明白了:

Tick数据解析与清洗流程 交易所原始报文 CTP / FIX / 自定义格式 字段解析 LastPrice / Volume Turnover / Bid/Ask 数据清洗 去重 / 排序 / 校验 异常值处理 存储到数据库 ClickHouse / InfluxDB 核心字段清单 • LastPrice - 最新成交价 • Volume - 累计成交量 • Turnover - 累计成交额 • BidPrice1 - 买一价 • AskPrice1 - 卖一价 • UpdateTime - 行情时间 • UpdateMillisec - 毫秒 常见坑点 ⚠ Volume是累计值 ⚠ Turnover含合约乘数 ⚠ 时间精度到毫秒 ⚠ Bid/Ask价差校验 ⚠ 夜盘数据重置问题 ⚠ 同一毫秒多条数据 ⚠ 合约乘数因品种而异

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
}
避坑指南: 我曾经在解析时直接把UpdateTime和UpdateMillisec拼成字符串,结果发现毫秒数不足三位时(比如50毫秒),拼出来是"14:30:25.50",导致时间格式不统一。正确的做法是用:03d补零。

3.6 字段之间的逻辑关系

这几个核心字段不是孤立的,它们之间有内在联系。我总结了几条校验规则:

校验规则 说明 异常处理
BidPrice1 < AskPrice1 买一价必须小于卖一价 标记为无效数据,丢弃或修复
Volume ≥ 0 且递增 成交量不能为负,且只能增加 若减少,检查是否换交易日
Turnover ≥ 0 且递增 成交额逻辑同Volume 同上
LastPrice 在涨跌停之间 不能超出UpperLimit/LowerLimit 超出则标记为异常
时间戳单调递增 后一条Tick时间 ≥ 前一条 乱序则重新排序

这些规则看起来简单,但实际跑数据时,每一条都可能出问题。我记得有一次,某个交易所的行情网关出了bug,连续发了3秒的BidPrice1都大于AskPrice1。要不是有校验规则,这些脏数据就直接进数据库了。

3.7 小结

原始Tick数据的结构,说白了就是"时间 + 价格 + 量 + 盘口"四个维度。你只要把每个字段的含义搞清楚,把常见的坑避开,解析起来并不复杂。

我个人习惯是,在解析阶段就把数据清洗的逻辑嵌进去——比如校验Bid/Ask关系、检查Volume是否递增、统一时间格式。这样后面存储和分析时,能省掉很多麻烦。

嗯,今天就先聊到这儿。下一节我们会讲如何把这些解析好的数据,高效地存到数据库里。


专注资料整理