简介:一份面向职业交易者和进阶投资者的通达信Level2数据实操指南,围绕逐笔成交、委托队列、资金流向与盘口异动等核心维度,解析主力资金动向、虚假挂单和大单拆分等实战信号,帮助读者建立盘口阅读能力与信息优势。资源包共3个文件,包含可交互的HTML说明页面、inscode代码配置和gitignore项目配置,压缩后仅4KB,轻量便捷,适合下载后直接打开对照学习,或按需整合进自己的看盘环境。目前已有1270人学习,内容兼顾Level2数据语言的解读、定制版面搭建与模拟实战路径,并给出挑选优质Level2服务的关键标准,如极速行情、功能完整度与专属投顾支持。整体以代码与页面组合的形式呈现,既说明数据维度含义,也给出落地到看盘页面的方法,是一份从入门到实战的浓缩型参考资料。
1. 通达信Level2数据指南:把盘口从「看不全」变成「可计算」
盯盘时最难受的一种场面是:买一大单挂着,价格却死活不涨,撤了又挂、挂了又撤;普通行情只到五档,根本看不穿五档之外还压着多少卖单。通达信这类行情终端里的Level2数据,就是冲着这类问题来的——十档行情、逐笔成交、委托队列,能把「看不全」的盘口变成「可计算」的结构。这个标题里的[代码]部分,目标是把Level2原始数据解析成能放进策略的结构化表格,再从中算大单流向、盘口压力差一类信号。适合正在做量化、盘口研究和短线回测的开发者;只想看图不想写代码的人,这篇帮不到你。数据源按正规订阅渠道获取,拿到手之后的解析、存储、计算,才是这条主线要解决的问题。
2. Level2数据到底长什么样:先分清快照流、逐笔流和委托队列
很多人拿到Level2数据后的第一反应是「这不就是多几档行情」。实际上刚导出的原始文件像个黑匣子:二进制流、字段缩写、奇怪的单位,直接开解析大概率踩坑。Level2的核心增量是三类数据:十档快照、逐笔成交、委托队列。它们频率不同、字段不同、能回答的问题也不同。解析代码写崩,往往是因为没先想清楚这三类数据各自的边界。
2.1 快照流和逐笔成交流:两个数据源的分工不同
快照流是定时间隔推送的盘口状态汇总视图,通常是买卖各十档的价位和委托量,附带最新价、今开、最高、最低、累计成交量额。它像每隔几秒拍一张照片,记录的是「此刻盘口挂单长什么样」。逐笔成交流则不同,它忠实记录每一笔已经发生的成交明细,包含成交时间、价格、数量、金额和主动性方向。它像流水账,回答的是「刚才到底发生了什么」。两者的关系是互补而非重复:快照描述状态,逐笔描述事件。
| 维度 | 快照流 | 逐笔成交流 |
|---|---|---|
| 内容 | 十档盘口、最新价、累计量额 | 单笔成交明细 |
| 推送方式 | 固定间隔推送 | 每笔成交触发推送 |
| 核心用途 | 观察盘口厚度与挂单分布 | 还原资金行为与成交结构 |
| 数据量级 | 一天约几千到几万条/标的 | 一天约几万到几十万条/标的 |
委托队列是第三类数据:把买一或卖一这一档的排队委托明细列出来,能看到某个大单是真排队还是不断撤换。它的细节比快照丰富,但通常只覆盖最优一档或若干档,不是全市场深度。三类数据建议分类存储、分类处理,不要混进同一张表,否则后续做盘口指标时字段语义会被搅乱。
2.2 三个容易错的基本单位:时间、价格、数量
解析Level2最容易翻车的不是逻辑,是单位。不同来源给的数据,时间可能是毫秒或微秒,价格精度可能是0.01元或0.001元,数量可能是股也可能是手。这类错肉眼几乎发现不了,直到对账或回测时数字对不上才暴露。
| 字段 | 常见单位/精度 | 建议统一基准 |
|---|---|---|
| 时间 | 当日0点起毫秒/微秒偏移 | 毫秒 int,另存 YYYYMMDD 日期 int |
| 价格 | 0.01 / 0.001 元,部分源放大100/1000/10000 | 整数 = 实际价 × 1000 |
| 数量 | 股或手 | 一律转成「股」 |
| 成交金额 | 元或千元 | 整数 = 元 × 1000,与价格精度一致 |
为什么数据源之间单位差别这么大?一个原因是上游行情协议本身就存在多套规格,另一个原因是部分落地存储为了压缩空间做了缩放。这几乎是所有做Level2接入的人都会在不同来源之间遇到一次的事。我一般会在解析前先写一个单位配置字典,把所有原始值统一过一遍转换,绝不在后续代码里临时乘除,宁可转换后多打印一次,也不要在策略里猜单位。
2.3 规范化:统一基准能省掉八成对账麻烦
规范化做得好,第3章的解析代码只需要处理一种口径。时间键和代码键是两个核心:快照和逐笔必须用同一种时钟基准(当日偏移毫秒),否则对齐时差三秒是常事;证券代码统一成去掉前导0后的整数,或者统一附加市场位,避免「600000」和「1600000」这类市场歧义。
# 建议在解析配置里统一声明字段来源和缩放系数 FIELD_SPEC = { "price": {"raw": "last_price", "scale": 1000}, # 原始价格 × scale 后按整数存储 "volume": {"raw": "volume", "scale": 1}, # 原始量已经是股 "amount": {"raw": "amount", "scale": 1000}, # 金额按 元×1000 存,与价格一致 }这段配置的价值在于把「单位约定」收敛到一个文件里。解析、清洗、回测三个模块都从FIELD_SPEC取值,任何人改单位只需改这一处,不会出现「这里除了1000、那里又乘了1000」的连锁错。实际使用中,我还会在配置里加一个SCHEMA_VERSION字段,解析脚本变更时同步加版本号,方便追溯哪些历史文件是用旧规则生成的,哪些需要重算。
3. 用Python解析Level2原始数据:从字节流到DataFrame
3.1 为什么解析前先定义结构化数组:内存和速度差出数量级
很多第一次写Level2解析的人直接用list of dict,解析几十万条还能忍,全市场几百万条就跑不动了。常见做法是先定义NumPy结构化数组:字段固定、类型紧凑,内存占用往往是dict方案的几十分之一。以逐笔成交为例,结构定义如下。
import numpy as np import pandas as pd TICK_DTYPE = np.dtype([ ("ts", "<u8"), # 时间戳:当日0点起毫秒数 ("date", "<u4"), # 交易日:YYYYMMDD 整数 ("code", "<u4"), # 证券代码:去掉前导0后的整数 ("price", "<u4"), # 价格:实际价格 × 1000 取整 ("volume", "<u8"), # 成交量:单位股 ("amount", "<u8"), # 成交金额:单位 元 × 1000,与价格精度一致 ("side", "i1"), # 主动性方向:1主动买 -1主动卖 0集合竞价 ])这里每个字段都取了保守的整数类型。价格用无符号整型而不是浮点,是因为浮点在大量累加时会有精度漂移,整数完全没有这个问题。时间用无符号整型而不是datetime对象,是因为datetime内存开销大、比较慢,回测时按毫秒对齐用整数查数组最快。side这种取值只有三种的字段,1字节有符号整型就够,没必要为它花4字节。结构化数组定义好后,后面所有解析函数都围绕它来填充,数据格式变更时只需要改这一处dtype。
3.2 用固定长度记录流的批量解析法
实际拿到的Level2原始数据通常是固定长度的二进制记录流。解析思路统一:确认单条记录字节数,用NumPy的frombuffer按字段偏移一次性切出所有字段数组,再组装成结构化数组。下面代码假设每条记录25字节、小端序、字段顺序是时间(8)+代码(4)+价格(4)+量(8)+方向(1),实际按你的数据源文档修改offset和dtype即可。
def parse_tick_bin(buf: bytes): """把逐笔成交二进制流解析成结构化数组 单条记录结构(小端序): ts <u8 偏移0 时间戳,当日0点起毫秒 code <u4 偏移8 证券代码整数 price <u4 偏移12 价格整数(实际价×1000) volume <u8 偏移16 成交量(股) side i1 偏移24 主动买卖方向 合计 25 字节/条 """ n = len(buf) // 25 if n == 0: return np.zeros(0, dtype=TICK_DTYPE) arr = np.zeros(n, dtype=TICK_DTYPE) arr["ts"] = np.frombuffer(buf, dtype="<u8", count=n, offset=0) arr["code"] = np.frombuffer(buf, dtype="<u4", count=n, offset=8) arr["price"] = np.frombuffer(buf, dtype="<u4", count=n, offset=12) arr["volume"] = np.frombuffer(buf, dtype="<u8", count=n, offset=16) arr["side"] = np.frombuffer(buf, dtype="i1", count=n, offset=24) return arr逻辑说明:先用总字节数除以单条记录长度算出条数,然后按字段偏移依次切出整列数组。frombuffer是零拷贝读取,比循环struct.unpack快两个数量级以上。参数说明里最关键的是偏移量,它由前面字段的字节数累加得到:8+4+4=16是volume的起点,16+8=24是side的起点。如果你的记录带文件头或字段顺序不同,只要重新累加一遍偏移量即可,解析函数本体不用改。另外提醒一句:如果上游用了结构体对齐,单条记录的实际字节数可能大于字段宽度之和,不要凭字段和猜长度,先对比两条相邻记录的起始地址确认真实步长。
3.3 快照流解析与逐笔的时间对齐
快照流的解析方式和逐笔相同,只是字段更多:买一到买十的价格和量、卖一到卖十的价格和量,加上最新价和累计量。解析完成后最重要的工作是时间对齐:一笔成交发生在两个快照之间,要判断它撞在什么盘口状态下,必须找到它之前最近的一条快照。用NumPy的searchsorted一步完成,不要用循环遍历。
def align_ticks_to_snapshot(ticks, snaps): """给每条逐笔成交找它之前最近的一条快照 ticks: 逐笔结构化数组 snaps: 快照结构化数组,需含 ts 字段 返回: 与ticks等长的快照索引数组,-1表示该时刻之前还没有快照 """ idx = np.searchsorted(snaps["ts"], ticks["ts"], side="right") - 1 return idx # 用法:取对齐后该笔成交对应的买一价 idx = align_ticks_to_snapshot(tick_arr, snap_arr) snap_bid1 = snap_arr["bid_price1"][idx]逻辑说明:searchsorted返回的是插入位置,减1就是「小于等于当前时间」的最后一条快照索引。参数side="right"表示时间相等时取右侧插入位置,保证对齐到最新一条快照而非更早那条。对齐之后,你就能在逐笔成交上附加那一刻的十档状态,后面算「大单连续打穿买一」这类信号就顺理成章了。快照字段名建议固定为bid_price1到bid_price10、bid_vol1到bid_vol10,以及ask_price1到ask_price10、ask_vol1到ask_vol10,从解析到回测都用同一套命名,避免语义混乱。
3.4 解析完先别急着用:四行代码做自检
解析完还不能直接用,先跑四个检查:价格范围是否合理、时间是否单调、价量额是否互相吻合、价格精度是否符合预期。每个检查两三行代码,能拦掉八成低级错误。
import pandas as pd import numpy as np df = pd.DataFrame(tick_arr) # 1) 价格粗查:不可能是负数,也不该出现离谱极值 print(df["price"].min(), df["price"].max()) # 2) 时间乱序检查:负值个数就是乱序记录数 print((df["ts"].diff() < 0).sum()) # 3) 价量额互查:用相对误差,数量级错会很明显 denom = df["amount"].replace(0, np.nan) ratio = ((df["price"] * df["volume"] - df["amount"]).abs() / denom).max() print(ratio) # 4) 精度检查:如果 price 几乎全是 10 的倍数, # 说明价格精度可能是 0.01 而不是 0.001,需复核缩放系数 print((df["price"] % 10 == 0).mean())逻辑说明:第三项利用了前面统一的整数单位,price乘以volume应该等于amount的量纲,因为两者都放大了1000倍,乘积正好抵消。相对误差在千分之一以内属于正常尾差,如果差出10倍或100倍,基本可以断定某一列的单位配置错了。第四项更隐蔽:价格按0.001元精度存时,整数尾部0~9都会有,但如果数据源实际精度是0.01元,那么几乎所有的price都是10的倍数,这一项能直接暴露缩放系数配错的问题。这套自检在每次接入新数据源、升级解析脚本时都要重跑一遍。
4. 存储与管理Level2数据:按天分文件,别把内存当仓库
4.1 Parquet比CSV和Pickle都靠谱
Level2数据写回磁盘,常见做法是Parquet格式。它是列式存储,压缩率高,读的时候可以只读部分列;CSV解析慢、占空间;Pickle虽然存取快,但格式锁定在Python,换环境就麻烦。保存和读取的代码很简单,但有一个参数值得调:compression建议用zstd,压缩率和读取速度的平衡比gzip好不少。
def save_daily(tick_df: pd.DataFrame, date: int, path: str): """按日保存逐笔数据到parquet date: YYYYMMDD 整数,比如20240603 path: 存放目录,目录需要已存在 """ tick_df.to_parquet(f"{path}/{date}.parquet", index=False, compression="zstd") def load_daily(date: int, path: str) -> pd.DataFrame: return pd.read_parquet(f"{path}/{date}.parquet")逻辑说明:按日分文件的模式,文件名为date.parquet,后面做按日期范围回测时直接生成文件路径列表即可,不需要维护数据库索引。参数说明里,index=False必须写,否则pandas会把行号也存进文件,白白占空间。环境里需要装pyarrow作为parquet后端,标准安装命令即可。数据量再大的场景,建议用pyarrow.dataset做分区读取,只扫描需要的日期文件,而不是每次都读整表。
pip install pyarrow估算一下数据量:一只活跃股票一天的逐笔成交在几万到几十万条之间,全市场几千只股票全部做,单日逐笔就是千万量级。快照流按3秒一档、每只股票一天约4800条,全市场同样千万级。两者加起来,单日原始数据几个GB不稀奇。所以存储设计不是锦上添花,而是还没跑回测就会被数据量教做人的地方。
4.2 按标的和按日分块:别一次性加载全市场
全市场Level2一天的逐笔数据,放内存不是不行,但会挤掉回测程序其他模块的余地。我的经验是:按日加载一个标的或一个板块,跑完即释放,比一次性全量加载省心得多。用DataFrame按code分组处理就能实现。
def process_one_day(date: int, data_path: str, code: int): """回测单日单标的数据 code: 整数证券代码 """ df = load_daily(date, data_path) target = df[df["code"] == code].copy() # 只保留一个标的 # 处理完后 target 离开函数作用域即可被回收 return target逻辑说明:用code过滤后.copy()是为了断开与全量DataFrame的引用关系,让全量数据可以尽早被垃圾回收。如果你确认后面不再用全量数据,不copy直接过滤也行,但多数回测脚本后面还会处理其他标的,建议copy。也有必须全量加载的场景,比如做全市场横截面统计、计算某个因子需要同一时刻全市场的十档状态,这时建议用pyarrow的dataset只读需要的列而不是整表。另一个折中方案是先按日聚合出中间特征,比如每5分钟的大单净流入,把中间结果落地,回测只加载中间结果——原始逐笔归档不用常驻。
4.3 历史回测怎么读最省事:日期迭代而不是一次性加载
历史回测最容易写崩的地方就是一次性把所有天数的数据读进内存,内存翻车后再回头改结构。常见做法是写一个日期迭代器,按天读、按天算、按天存结果。即使数据覆盖好几年,内存峰值也只是「单日数据量+累积结果」,可控得多。
import os def iter_dates(start: int, end: int, data_path: str): """返回闭区间[start, end]内实际存在的日期文件列表""" return sorted( int(f[:-8]) for f in os.listdir(data_path) if f.endswith(".parquet") and start <= int(f[:-8]) <= end ) # 回测主循环:一天一天读,结果累计,内存峰值可控 for d in iter_dates(20240101, 20240630, data_path): daily = load_daily(d, data_path) # 在这里跑当天的信号计算,把结果追加到总表逻辑说明:文件名去掉.parquet后就是日期,用切片f[:-8]提出来转int,配合闭区间过滤,这是最轻量的索引。参数说明里要注意文件命名的固定长度:只有day.parquet这种固定命名才有f[:-8]可切;如果命名变成code_date.parquet,就要用split方法分段取值。命名规范在存储设计时就要定死,后面所有读取代码都依赖它。按日分文件还有一个隐藏收益:清洗逻辑升级时不用全量重跑。某一批数据发现脏点,只需要重新解析并覆盖对应日期的文件。代价是要保证解析脚本的确定性:同一份原始数据、同一个版本脚本必须输出完全相同的文件。
5. Level2数据解析避坑指南:五个反复翻车的点
这五条基本是我踩出来的血泪经验。它们的共同点是:数据看起来「大致正常」,但细节经不起对账。解析脚本写完后,先用一个标的、一天的数据做全链路对账,再放量跑全市场,能省掉后面大量返工。
5.1 价格精度对不上,买一价和逐笔价差十倍
现象:同一时刻,逐笔成交价显示12.34,快照里的买一价显示1.234,两个数据源看起来差了一个数量级。 原因:两个数据流的精度约定不一致,一个按0.01元存,一个按0.001元存,缩放系数还不同。 解决:所有字段先归一化到同一个精度再解析,统一用整数=实际价×1000。核对方法很简单:拉出当日最高价和最低价,分别按缩放系数换算,看能否对上行情软件里显示的极值;再随机抽10条逐笔,和行情软件的手工明细对照价格和成交量。如果精度不对,通常所有价格都差同样的倍数,这个规律能快速判断是整体缩放问题还是个别字段异常。
5.2 逐笔总量和快照累计量永远对不上
现象:把某标的当日逐笔成交量加总,和快照里的累计成交量一对比,差一截,怎么查都查不平。 原因:快照的累计量包含集合竞价和盘后固定交易等非连续竞价时段的成交,而逐笔流通常只覆盖连续竞价;另外撤单中部分成交的记录口径也不同。 解决:不要做全量核对。只比较连续竞价区间(9:30-11:30、13:00-15:00)内逐笔增量和快照增量的差值。做法是取逐笔流中ts落在连续竞价区间的记录求和,再取相邻两个快照的累计成交量之差做对比。快照累计量一般是单调递增的,如果差值为负,就说明快照有遗漏或数据不连续。统计口径要在代码注释里写明「本统计仅覆盖连续竞价」,防止后人误解。
5.3 内存直接拉爆,回测脚本卡死
现象:加载全市场几天数据后,内存占用飙到几十G,程序无响应,只能杀掉重来。 原因:一个典型错误是让pandas用object类型存字符串代码和datetime时间,单条记录的开销比整数类型大几十倍。 解决:按第3章的结构化数组存储,代码和时间全用整数;回测按天迭代而不是一次性load全部。先拿10只票跑通流程,确认内存和耗时曲线后,再按比例估算全市场。别一上来就全市场全量,先用小样例把解析、存储、读取三条链路测匀速,再放开范围。
5.4 时间戳乱序,成交顺序看起来错乱
现象:打印某几分钟的记录,时间戳忽大忽小,成交顺序明显不对。 原因:数据源可能是多路并发合流,断线重连或并发写入都会导致文件物理顺序不等于时间顺序。 解决:解析后按ts排序;如果数据源带有行情序号字段,先按序号校验再按ts排序。不要假设文件里的顺序是对的,所有依赖先后关系的计算都先做一次排序。乱序最常见的后果是资金流方向统计被颠倒:一笔主动买因为排序错误被当成主动卖,净流入直接反号,所以排序不是可选项,而是所有时序计算的前置步骤。
5.5 除权日当天,历史统计突然失真
现象:用历史数据验证某个大单买入信号,发现在除权日前后信号频率暴增或骤减。 原因:除权除息导致的标称价格跳空,被当成真实成交价差来计算信号,干扰统计。 解决:加载除权除息表做复权处理。除权除息表一般包含除权日、每股派息、送转比例等字段,向前复权的常见做法是以最近日期为基准,把历史价格按复权因子逐日调整。短线盘口研究一般用前复权视角,更符合盘中感知;长周期跨年回测务必明确复权方式并在结果里标注。如果信号只依赖相对价位比如十档价差,复权的影响可能不大;但一旦涉及绝对价格、涨停价判断或收益率计算,就必须处理复权。不做这一步,后面的统计结论都不可信。
6. 把解析好的Level2数据用起来:大单流与盘口压力差的代码
盘口研究里,Level2数据最直接的价值是两个:还原大单行为、量化盘口压力。下面给两个基础信号的计算代码,都是几十行内能跑通的口径。重点不是胜率,而是帮你建立「从数据到信号」的完整链路。
6.1 大单资金流统计
# 假设 tick_df 来自第3章解析结果,字段单位与第3章一致 # amount = 元 × 1000,所以 50 万 = 500_000 × 1000 big = tick_df[tick_df["amount"] >= 500_000_000] buy_flow = big.loc[big["side"] == 1, "amount"].sum() / 1000 sell_flow = big.loc[big["side"] == -1, "amount"].sum() / 1000 net_flow = buy_flow - sell_flow print(f"大单净流入: {net_flow:.0f} 元")逻辑说明:先按金额阈值筛出大单,再按主动买卖方向分别求和。主动买意味着一笔成交由买方主动吃掉,通常解读为买入意愿更强;主动卖相反。参数说明里有两个口径要自己定:一是单笔金额下限,50万适合中型个股,大盘股建议调到100万以上;二是是否排除集合竞价,side等于0的记录默认建议丢弃,它们对连续竞价场景的意义不大。
6.2 委买委卖压力差与十档深度
# 假设 snap_df 是快照流解析结果,字段为 bid_vol1..bid_vol10, ask_vol1..ask_vol10 bid_cols = [f"bid_vol{i}" for i in range(1, 11)] ask_cols = [f"ask_vol{i}" for i in range(1, 11)] bid_total = snap_df[bid_cols].sum(axis=1) ask_total = snap_df[ask_cols].sum(axis=1) # 压力差:正数表示买盘量更大,负数表示卖盘量更大 pressure = (bid_total - ask_total) / (bid_total + ask_total)逻辑说明:这个指标用十档总量替代普通行情的五档总量,是Level2相对普通行情的典型增量。参数说明里,sum的axis=1必须写,否则得到的是每列的和;分母加总买卖两方总量做归一化,是为了让不同标的、不同价位的绝对量可比。实际使用中,建议分别算前五档和全十档两份压力差,对比两者的差异,能看出「五档之后藏了多少挂单」,这正是普通行情看不到的部分。
6.3 信号验证的一个朴素习惯
盘口信号天然带一点玄学,同一个大单流指标在不同标的上的表现可能完全不同。我自己的习惯是:新定义一个信号后,先不回测整个策略,只做一件事——把过去20个交易日里这个信号的出现次数和次日开盘后的价格走向拉出来看分布。手动翻图几十次,比任何统计检验都能更快建立起「这个信号到底有没有用」的感觉。警惕过拟合:Level2的字段足够多,想组合出漂亮的信号很容易,但样本外大概率失效。
最稳妥的路径是:先用一个极简口径跑通全链路,再逐步加过滤条件,每加一个条件都必须看到样本内外表现一致才保留。希望今天的这份指南能帮到你,让你的Level2数据从一坨二进制,真正变成一个可以反复验证的信号源。
本文还有配套的精品资源,点击获取