做量化研究的人,特别是做高频和日内策略的,应该都有过这种崩溃时刻:好不容易拿到Level-2行情权限,写了个脚本挂了几天,攒下来几百GB的tick数据,然后要回测的时候发现——数据根本查不动。MySQL单表几亿行,一条时间范围查询卡到怀疑人生;Redis倒是快,可那容量和成本够买几台服务器。这就是金融量化交易最容易被低估的一环:Tick级行情数据底座。这篇文章我会把我们团队在量化数据底座上的架构设计思路完整拆一遍,核心存储选用TDengine时序数据库,覆盖快照行情、逐笔成交、逐笔委托的接入、存储、回放和调优,适合正在做量化系统、或者准备用时序数据库替代原有SQL方案的朋友参考。
1. 行情的特殊性:为什么通用数据库扛不住Tick级数据
1.1 Tick数据的“恐怖”量级跟普通业务数据完全不同
普通业务系统里,订单表几千万行就算大表了,但在行情数据面前,几千万行可能就是一天的快照量。以A股市场为例:全市场5000多只股票,Level-2快照每3秒推送一次,交易时间4小时,每只股票一天就有约4800条快照,全市场一天下来就是2400万条左右。这还只是快照。
如果再把逐笔成交也收下来,情况会更夸张。一只活跃的股票一天可能产生几万到几十万笔逐笔成交,全市场平均下来,一天的逐笔成交数据量轻松突破1亿条。Level-2还有逐笔委托,数据量又是逐笔成交的好几倍。我用一张表做个汇总,方便对量级有直观概念:
| 行情类型 | 单只活跃标的日数据量 | 全市场日数据量估算 |
|---|---|---|
| Level-2快照 | 约4800条 | 2400万条左右 |
| 逐笔成交 | 数万至数十万笔 | 1亿条以上 |
| 逐笔委托 | 数倍于逐笔成交 | 数亿条级别 |
换句话说,一个量化团队如果要做完整的Level-2研究,一天要喂给数据库的就是亿级行,一个月就是几十亿行,一年就是几百亿行。更关键的是,这些数据不是匀速到来的。开盘前15分钟和收盘前15分钟,tick密度是平时的数倍,如果行情源是突发式推送,数据库要扛住的是瞬间每秒几十万条的写入尖峰,而不是平均值。
在这种量级下,第一反应是上MySQL或者PostgreSQL,但实际用下来就会发现几个致命问题:
- 写入吞吐跟不上。场景是突发式的,tick密集时段单条insert加事务,数据库的瓶颈很快就会被顶穿,一进场就跟不上行情节奏。
- 时间范围查询走全表扫描。就算在时间戳字段建了索引,几亿行的索引树也大得离谱,回表、扫描、排序,一个区间查询经常要几十秒,这种延迟对策略研究来说完全不可接受。
- 存储膨胀严重。MySQL InnoDB行存储,一条含盘口20档的数据加上索引、页开销,可能膨胀到几百字节,一天的数据就是几个GB,跑一个月磁盘就告警。
这些问题的根源,不完全是数据库配置或者服务器性能不够,而是关系型数据库的设计目标从来就不是“时序数据”。业务系统的数据模型是实体的当前状态,而行情数据是一个无限追加的事件流,两者的存储和查询模式完全不同。你把事件流硬塞进实体建模的数据库里,等于让货车去干高铁的活,速度上不去是必然的。想明白这一点之后,换时序数据库就不是一个“要不要”的问题,而是一个“用哪个”的问题。
1.2 时序数据库选型:为什么是TDengine而不是别的
被MySQL搞了几轮之后,我们把目光转向时序数据库。当时对比过InfluxDB、TimescaleDB、QuestDB、TDengine几个方案,最终选了TDengine,原因很实际。我们最核心的诉求不是什么花哨的功能,就两条:一是写入峰值能不能吃掉开盘时的突发流量,二是查询能不能做到秒级返回,TDengine在这两点上都比另外几个方案更符合我们当时的团队人力储备。InfluxDB的元数据管理在大规模多标的场景下会有一些额外维护成本,TimescaleDB还是基于PostgreSQL的架构,写入瓶颈没有本质解决,QuestDB虽然性能数据很漂亮,但周边生态和文档成熟度不如TDengine,国内的技术社区和商业支持也更难获得。
第一,写入模型完全贴合。TDengine把“一张表对应一个数据采集点”作为核心模型,天然适合每个证券标的一条子表,写入时按时间顺序批量追加,不搞随机更新,写入吞吐非常高。官方给的数据是千万条每秒,我们自己的环境虽然到不了那个数,但单机稳定跑几十万条每秒是没问题的,行情端根本打不爆它。这一点在实际灌历史数据的时候就能感受到:几亿条tick灌进去,速度基本稳定,没有出现关系型数据库那种越写越慢的衰减。
第二是存储和查询一体化的设计。TDengine自带的存储引擎按时间分区,采用列式存储,时间戳天然有序,所以既能做高效的压缩,又能支持时间范围的极速扫描。我们线上表里存了25亿条tick数据,单标的区间查询基本是几十毫秒到几百毫秒,全市场扫一遍按分钟聚合也能在秒级完成,这在MySQL上是不可想象的。存储的压缩效果也很明显,行情数据有大量重复的盘口字段,压缩率能做到1/10左右。
第三是数据保留策略做得很省心。行情数据其实有“保鲜期”,不是所有历史数据都需要全量留着的。TDengine建表时可以指定keep参数,超过保留期的数据会自动清理,不用自己写定时任务删数据。我们现在的策略是:最近一年的tick原始数据全量保留,再往前的历史数据只保留分钟级聚合结果,这样存储成本可控,研究需要用到更长周期数据时也不至于没有底料。
安装部署这块也值得一提。TDengine的安装包很轻量,Windows和Linux都有现成的一键安装包,起来之后一行SQL就能建库建表,不需要像大数据全家桶那样动辄十几个组件要维护。对于真正要快速验证一个数据底座方案的技术团队来说,这个低门槛优势是很实在的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. TDengine的数据模型与Tick行情的映射关系
2.1 超级表、标签、时间戳三要素怎么理解
TDengine有两层数据模型,理解对了,表结构设计就成功了一半。第一层是“普通表”和“子表”:每张子表对应一个具体的采集对象,在量化场景里,采集对象就是具体的证券标的,比如某一只股票、某一个期货合约。每张子表的第一列必须是时间戳,后续列是采集到的指标值。第二层是“超级表”:把同一类型的多张子表集合成一个逻辑上的大表,外层的标签(TAGS)用来标识每一张子表之间的差异。比如股票代码、交易所、证券类型这些不随时间变化的属性,都可以作为标签字段,而不是普通数据列。
为什么这样设计有意义?因为行情查询几乎都是按“标的+时间范围”两个维度来的。子表天然把同一个标的的所有tick数据集中在一起,查询某个标的某个时间段的行情时,TDengine直接定位到那张子表,扫描范围被限制在一张表内,速度自然快。而超级表又允许跨标的查询,比如要“找出所有股票在某个时刻的价格”,用标签过滤就能在超级表层面扫一遍,非常灵活。
时间戳精度也要提前想清楚。Tick级行情的时间粒度是毫秒甚至微秒级别的,TDengine建库的时候可以指定PRECISION参数,支持ms和us两种精度。A股Level-2行情时间戳精确到毫秒就够了,但如果做期货或者数字资产,很多交易所的数据源会精确到微秒,建议建库的时候就按us来,一开始就想清楚后面能省掉很多麻烦。我们当时有个库里存的是毫秒精度,后面接数字资产数据时想升级成微秒,结果必须重建数据库,实打实折腾了一个周末。
还有一个设计上的经验:时序数据库里表的数量不是问题,TDengine单机支撑几万张表是常态,所以不用为了减少表数量而把多个标的混在一张表里。相反,如果一张表里塞了太多标的,查询时的分区裁剪和数据隔离都会大打折扣,反而得不偿失。
2.2 表结构设计实战:快照行情、逐笔成交、逐笔委托分治
很多团队一上来就想着把所有行情数据塞进一张超级表,这是最容易踩的坑。快照行情、逐笔成交、逐笔委托虽然都带时间戳,但字段结构、数据频率、查询场景完全不同,混在一张表里,超级表的字段会膨胀得非常畸形,查询性能也会互相拖累。合理的做法是按照行情类型分三张超级表,每张表只存一类数据。
先看快照行情,也就是通常说的盘口快照。它反映某个时点上证券的交易状态,除了最新价、成交量、成交额,还包含多档买卖盘口。对应的超级表结构大概是这样的:
sql复制CREATE STABLE tick_snapshot (
ts TIMESTAMP,
last_price DOUBLE,
last_vol INT,
total_volume BIGINT,
total_amount DOUBLE,
bid1 DOUBLE, bid_vol1 INT,
ask1 DOUBLE, ask_vol1 INT,
bid2 DOUBLE, bid_vol2 INT,
ask2 DOUBLE, ask_vol2 INT
) TAGS (
code NCHAR(20),
exchange NCHAR(10),
asset_type NCHAR(10)
);
这里有一个设计要点:trade_date(交易日期)之类的字段不要放进普通列,也不要在普通列上建索引,它应该作为标签字段存在。因为一张子表本身就是针对某一个标的一个时间序列,日期属性是固定的,没必要在每一行里重复存储。而且TDengine对标签字段的过滤做了优化,查询时按标签过滤,可以跳过大量无关子表,速度提升非常明显。
再看逐笔成交。逐笔成交是每发生一笔真实交易就会产生一条记录,字段比快照简单得多,主要是成交时间、成交价、成交量、成交性质(主动买/主动卖)等。它的超级表可以设计成这样:
sql复制CREATE STABLE trade_tick (
ts TIMESTAMP,
price DOUBLE,
volume INT,
amount DOUBLE,
trade_type TINYINT
) TAGS (
code NCHAR(20),
exchange NCHAR(10),
asset_type NCHAR(10)
);
trade_type 用TINYINT而不是用字符串存“买”“卖”,首先是为了压缩率和写入性能,其次是为了后续因子计算时能直接用数值判断。行情数据存进来是要给策略算用的,能用数值就不要用字符串,这是数据归一化阶段就要定下的规矩。
逐笔委托的字段更复杂一些,包括委托方向、委托价格、委托数量、委托单号等,数据量也最大。如果团队初期存储资源紧张,可以先不上逐笔委托,优先保证快照和逐笔成交,等回测框架对逐笔委托有明确需求了再扩展。数据底座不是一步到位的,能够按阶段迭代反而更稳。
子表命名上也值得讲究。TDengine允许子表在插入时自动继承超级表的标签,自动建表很方便:INSERT INTO t_000001_sz USING tick_snapshot TAGS('000001','SZ','stock') VALUES(now, 10.5, 100, ...)。子表名建议用不带特殊符号的稳定字符串,比如t_加证券代码和交易所后缀,统一规则,避免管理混乱。这里聊一个我见过很多次的细节:有的人喜欢直接用证券名称当中文表名,结果碰到股票改名、特殊字符、代码冲突,维护起来非常痛苦。
3. 数据接入管道:从行情源到TDengine的完整链路
3.1 行情源接入与数据归一化
表结构设计好之后,最考验工程能力的部分是写入链路。我们实际生产环境的链路不算特别复杂,但每一个环节都踩过坑,值得拆开讲。
行情源一般分两类:一类是交易所直连或券商提供的行情SDK,会以回调方式推送快照和逐笔数据;另一类是第三方行情服务商提供的文件或API接口。无论哪种,进入数据管道前都有一个共性问题:不同来源的数据格式、字段命名甚至精度可能都不一样,直接进库会把数据库结构搞乱,所以要先做一层数据归一化。
我们在这层做的事情有三件:
- 统一字段名和单位。比如有的行情源给的价格是“元”,有的是“分”,在归一化层统一换算成元,避免后续研究时每个模块都要各自处理一遍单位。
- 统一时间戳。有些源给的字段里带时区,有些是本地时间,归一化层统一转成标准时区的毫秒时间戳,并且标记数据来源,方便以后排查。
- 剔除脏数据和异常tick。比如成交量为负、价格跳空超过合理范围、时间戳逆序等,直接在接入层过滤掉,不要等入库了再清理,否则数据清理的成本会高很多。
归一化完成的数据会先写入一个内存队列,再由写入服务按批次提交给TDengine。有人问,为什么不用Kafka?当然可以用,如果团队已经有Kafka基础设施,在行情源和写入服务之间加一层消息队列,可以把数据管道解耦得更彻底。我们初期为了控制架构复杂度,直接用内存队列已经足够,等数据源增加之后再拆Kafka也来得及。金融数据底座的架构目标应该是先能用,再逐步优化,而不是一开始就把链路组件堆满。
3.2 批量写入与自动建表:性能和容错怎么平衡
TDengine的写入性能高度依赖批量提交,这一点必须要理解。如果来一条tick就INSERT一条,哪怕走原生协议,传输和事务开销也会把性能打下去几个数量级。正确做法是攒一批再提交,我们实践下来,单次批量在几百到几千条之间,写入吞吐和延迟能取得一个不错的平衡。
使用参数绑定写入是最推荐的方式。TDengine提供C/Python/Java/Go等语言的参数绑定接口,可以预先准备一条INSERT语句,然后批量绑定参数,最后一次性提交。这样既避免了SQL拼接的开销,也减少了网络往返,是写入性能最高的方式之一。
另外有一个特别有用的特性:自动建表。在写入时指定超级表和标签列表,如果对应的子表还不存在,TDengine会自动创建这张子表:
sql复制INSERT INTO t_000001_sz USING tick_snapshot TAGS ('000001', 'SZ', 'stock') VALUES (?, ?, ?, ?, ?);
用这种方式写入,整个管道的代码量会减少很多,不用自己判断标的子表是否存在,队列消费者直接无脑批量插入就行。这个特性在首次灌历史数据时尤其好用,几千个标的的表结构在数据进入的同时就自动全部建好了。
再聊容错。行情源偶尔会断连,TDengine本身有WAL(预写日志)机制来保证掉电数据不丢,但对于应用层来说,还需要维护一个“断点续传”的offset。我们的做法是在写入服务里记录最近一次成功写入的时间戳和行情源序号,进程重启后从这个点位重新拉取数据,保证数据管道中断时不会出现间隙。实践中,A股日内断连的概率不高,但夜盘和节假日前后系统维护容易出问题,有了这个续传机制,我们就不会在上午开盘后突然发现数据少了一段。
4. 查询回放:量化策略研究中最常用的几种读取模式
4.1 单标的区间回放与时间聚合
数据底座建好之后,最终要服务的是策略研究。量化团队日常对行情的读取套路其实非常固定,这里把最高频的两种查询模式列一下,也顺便给出对应的SQL写法。
第一种是单标的区间回放:给定股票代码和时间区间,把该区间内所有tick拉出来,用来复现当时盘面的走势。利用超级表和标签,SQL写起来非常简洁:
sql复制SELECT ts, last_price, total_volume
FROM tick_snapshot
WHERE code = '000001'
AND ts >= '2024-06-03 09:30:00'
AND ts <= '2024-06-03 10:00:00';
这背后实际上是定位到t_000001_sz这张子表,然后做顺序扫描,数据量极可控,几十毫秒到几百毫秒就能出结果。注意,这里不要图省事把code放在普通列里,再用 WHERE code=... 过滤,那样会变成全超级表扫描,性能完全不是一个量级。
第二种是时间聚合,也就是把tick数据按分钟或自定义周期聚合成K线。传统做法是从库里把tick全捞出来,在Python里循环处理,速度慢且吃内存。TDengine直接支持时间窗口聚合:
sql复制SELECT _wstart, _wend,
FIRST(last_price) AS open,
MAX(last_price) AS high,
MIN(last_price) AS low,
LAST(last_price) AS close,
SUM(last_vol) AS volume
FROM tick_snapshot
WHERE code = '000001'
AND ts BETWEEN '2024-06-03 09:30:00' AND '2024-06-03 15:00:00'
INTERVAL(1m) FILL(NULL);
一条SQL就把一分钟K线算完了,这也是时序数据库相比普通关系型数据库最值钱的“原生降采样”能力。高频因子研究经常需要做不同周期的数据转换,有了这个能力,整条计算链路能省掉一个复杂的开发模块。
4.2 全市场某一时点快照的取法与关键优化
第三种高频场景是取全市场某个时点的快照,比如“上午10点整所有股票的价格和成交量”。这类查询在计算横截面因子时非常常见,比如截面动量、行业分布、全市场流动性扫描。
在TDengine里取全市场快照,有个常用思路是用超级表加上时间窗口裁剪,再按子表取一条:
sql复制SELECT tbname, code, last_price, total_volume
FROM tick_snapshot
WHERE ts >= '2024-06-03 10:00:00' AND ts <= '2024-06-03 10:00:03'
PART
