1. 从零搭建A股分时交易数据管道:一个数据工程师的踩坑实录
做量化研究或者行情复盘的人,迟早会撞上同一个问题:日线数据满地都是,但分时数据——尤其是Tick级别或者分钟级别的成交明细——要么贵得离谱,要么格式乱得没法用。我最初接触A股分时交易数据,是因为想做一个日内动量策略的回测,结果发现免费的分钟数据接口要么限流,要么字段缺失,要么时间戳对不上。折腾了大半年,从接口选型到数据清洗再到存储优化,踩过的坑足够写一本小册子。这篇文章就把我整个搭建过程拆开来讲,包括数据源的取舍逻辑、字段解析的细节、存储方案的对比,以及那些只有真正跑过数据管道的人才会遇到的诡异问题。不管你是刚入门的数据爱好者,还是已经有一套半成品管道的开发者,应该都能从里面找到能直接抄作业的部分。
1.1 分时数据到底包含什么,为什么它比日线难搞
先把这个概念理清楚。A股分时交易数据,通常指两类:一类是分钟级K线,包含开高低收、成交量、成交额,时间粒度从1分钟到60分钟不等;另一类是逐笔成交明细,也就是常说的Tick数据,记录每一笔成交的时间、价格、手数、买卖方向。日线数据一天只有一行,分时数据一天有240行(分钟级)或者几万到几十万行(Tick级),数据量差了三到四个数量级。
这个量级差异直接带来了三个难题。第一是采集频率,日线数据收盘后跑一次就行,分时数据要么盘中实时拉取,要么盘后批量下载,前者对网络和接口稳定性要求极高,后者则面临数据完整性的校验问题。第二是时间对齐,A股有集合竞价、连续竞价、午间休市,不同数据源对9:15到9:25这段集合竞价数据的处理方式完全不同,有的把9:25那一笔单独列出来,有的直接合并到9:30的第一根K线里,如果不做统一,回测结果会差出好几个点。第三是复权处理,日线复权因子好算,分时数据遇到除权除息日,前复权还是后复权,对日内策略的影响非常直接。
我一开始图省事,直接用某公开接口拉分钟数据,结果发现它的成交量字段是“手”还是“股”都没标清楚,跟日线数据一对,差了100倍。后来才知道,不同数据源对“手”的定义不一样,有的按100股一手,有的直接给股数。这种细节,文档里往往不写,只能自己拿已知数据去反推验证。
1.2 数据源选型:免费、付费与自建的三角权衡
选数据源这件事,本质上是在成本、质量、稳定性之间做取舍。我前后试过四种方案,每种都有各自的适用场景。
方案一:公开财经接口。优点是零成本,适合做原型验证。缺点是限流严重,通常每分钟只能请求几次,拉全市场几千只股票的分时数据,跑一遍要几个小时。而且字段不统一,有的接口返回的JSON里时间戳是字符串,有的是毫秒数,解析逻辑得写一堆兼容代码。更麻烦的是,这类接口经常悄无声息地改字段名或者调整返回结构,你的管道可能某天早上突然就挂了。
方案二:券商提供的量化接口。如果你有券商账户,很多券商都提供Level-1甚至Level-2的行情接口。Level-1包含分钟级数据,Level-2包含逐笔成交和十档行情。优点是数据质量高、延迟低,缺点是通常绑定券商客户端,API文档写得比较粗糙,而且不同券商的接口风格差异极大。我用过的一家,它的分钟数据接口返回的是二进制流,得自己按协议解析,光调试解析代码就花了一周。
方案三:第三方数据服务商。市面上有几家专门做金融数据服务的公司,提供标准化的RESTful API或者本地数据库文件。数据质量稳定,字段定义清晰,支持复权处理。缺点是按流量或者按年收费,对于个人研究者来说,成本不低。但如果你的策略已经进入实盘阶段,这笔钱省不得。
方案四:自建采集系统。如果你有稳定的行情源(比如券商接口),可以自己写采集程序,把数据落到本地数据库。优点是完全可控,想存什么字段就存什么字段,想怎么复权就怎么复权。缺点是需要处理断线重连、数据补采、去重等一系列工程问题。我现在的生产环境就是这套方案,后面会详细讲。
提示:不管选哪种方案,一定要先拿几只股票、几个交易日的数据做交叉验证。比如用公开接口拉某只股票某天的分钟数据,再用券商接口拉同一天的数据,逐行对比开高低收和成交量。差异超过0.1%的,就要查清楚原因,是复权方式不同,还是时间戳对齐方式不同。
2. 分时数据管道的核心架构与字段解析
2.1 管道设计的四个关键模块
一套能稳定跑起来的分时数据管道,至少包含四个模块:采集、清洗、存储、服务。采集负责从数据源拉取原始数据;清洗负责字段标准化、时间对齐、异常值处理;存储负责把清洗后的数据落到合适的数据库;服务负责对外提供查询接口,供回测或者实时监控调用。
这四个模块的边界要清晰,但也不能过度解耦。我见过有人把清洗逻辑拆成十几个微服务,结果一个字段变更要改五六个地方,维护成本极高。我的做法是:采集和清洗放在同一个进程里,用管道模式串联,存储和服务分开。这样既保证了数据流转的连贯性,又不会让代码变得难以维护。
采集模块的核心是调度策略。分钟数据可以在收盘后批量拉取,Tick数据如果要做实时策略,就得盘中持续订阅。我采用的是混合模式:分钟数据盘后批量补采,Tick数据盘中实时订阅并落盘,盘后再做一次全量校验。这样即使盘中订阅漏了几笔,盘后也能补回来。
清洗模块最容易被低估。很多人觉得拉下来的数据直接存就行了,但实际上,原始数据里充斥着各种问题:时间戳重复、成交量突然放大一百倍(单位错误)、某分钟没有数据(停牌或者流动性太差)、价格超出涨跌停限制(数据源错误)。这些问题如果不处理,回测时会出现莫名其妙的巨额收益或者亏损。
2.2 字段标准化:那些文档里不会写的细节
分时数据的字段看起来简单,但每个字段都有坑。我整理了一份字段对照表,把常见数据源的字段名和标准化后的字段名做个映射。
| 原始字段名(示例) | 标准化字段名 | 类型 | 说明 |
|---|---|---|---|
| t / time / timestamp | trade_time | datetime | 统一转为北京时间,精确到秒 |
| o / open | open | float | 开盘价,单位元 |
| h / high | high | float | 最高价,单位元 |
| l / low | low | float | 最低价,单位元 |
| c / close | close | float | 收盘价,单位元 |
| v / vol / volume | volume | int | 成交量,统一为股 |
| a / amount / turnover | amount | float | 成交额,单位元 |
| pc / pre_close | pre_close | float | 前收盘价,用于计算涨跌幅 |
这里重点说三个坑。第一个是成交量单位。有的数据源给的是“手”,有的是“股”,还有的是“千股”。我遇到过最离谱的,同一份数据里,上午的成交量单位是手,下午变成了股,原因是数据源在中午做了系统升级。所以拿到数据后,第一件事就是拿一只高价股和一只低价股,用成交额除以成交量,反推每股均价,看是否在合理范围内。
第二个是时间戳的时区。大部分国内数据源给的是北京时间,但有些接口返回的是UTC时间,如果不做转换,你会发现9:30的开盘数据变成了1:30。更隐蔽的是,有些数据源在夏令时切换时会出问题,虽然国内不用夏令时,但如果数据源服务器在海外,就可能受影响。
第三个是集合竞价的处理。9:15到9:25是集合竞价阶段,9:25产生开盘价。有的数据源把9:25这一笔单独作为一条记录,时间戳是9:25:00;有的把它合并到9:30的第一根分钟K线里。这两种处理方式对日内策略的影响很大。我的做法是统一保留9:25的独立记录,并在清洗时标记出来,回测时可以根据策略需要决定是否使用。
2.3 复权处理:前复权、后复权与不复权的选择逻辑
分时数据的复权比日线复杂得多。日线复权只需要一个复权因子,分时数据因为涉及日内价格,复权方式的选择会直接影响策略信号。
前复权是以当前价格为基准,调整历史价格。优点是当前价格是真实的,适合做实时监控和展示。缺点是每次除权除息后,历史数据都要重新计算,存储成本高。后复权是以历史价格为基准,调整当前价格。优点是历史数据不变,适合做回测。缺点是当前价格是虚拟的,不适合展示。不复权就是原始价格,适合做日内策略,因为日内策略通常不持仓过夜,除权除息的影响可以忽略。
我的做法是:存储时保留不复权的原始数据,同时单独存一份复权因子表。回测时根据策略需要,动态计算复权价格。这样既节省了存储空间,又保证了灵活性。复权因子的计算方法是:除权除息日的理论开盘价除以实际开盘价,这个比值就是当日的复权因子。累积所有历史复权因子,就能得到任意日期的复权价格。
注意:复权因子在除权除息日当天会发生变化,如果你的管道在盘中运行,要确保复权因子表在开盘前已经更新完毕。我有一次忘了更新,导致当天所有回测信号都偏了2%以上。
3. 实操过程:从数据拉取到落库的完整实现
3.1 环境准备与依赖安装
我用的技术栈是Python + PostgreSQL + Redis。Python负责采集和清洗,PostgreSQL负责持久化存储,Redis负责缓存实时查询结果。这套组合的优点是成熟稳定,社区资源丰富,遇到问题容易找到解决方案。
# 创建虚拟环境 python -m venv venv source venv/bin/activate # 安装核心依赖 pip install pandas numpy requests sqlalchemy psycopg2-binary redis apschedulerPostgreSQL的安装就不展开了,网上教程很多。建库的时候注意两点:一是字符集用UTF8,二是时区设置为Asia/Shanghai。建表语句我放在下面,字段类型根据前面的标准化表来定。
CREATE TABLE minute_bars ( id BIGSERIAL PRIMARY KEY, symbol VARCHAR(10) NOT NULL, trade_time TIMESTAMP NOT NULL, open NUMERIC(10,3), high NUMERIC(10,3), low NUMERIC(10,3), close NUMERIC(10,3), volume BIGINT, amount NUMERIC(20,3), pre_close NUMERIC(10,3), created_at TIMESTAMP DEFAULT NOW(), UNIQUE(symbol, trade_time) ); CREATE INDEX idx_minute_bars_symbol_time ON minute_bars(symbol, trade_time);这个表结构里,UNIQUE(symbol, trade_time)是关键,它保证了同一只股票同一分钟不会重复插入。配合ON CONFLICT DO NOTHING,就能实现幂等写入,即使采集程序重复拉取同一时间段的数据,也不会产生重复记录。
3.2 采集程序的编写与调度
采集程序的核心逻辑是:遍历股票列表,对每只股票调用数据源接口,拿到原始数据后做初步清洗,然后批量写入数据库。这里的关键是错误处理和重试机制。网络请求随时可能失败,接口可能限流,数据可能为空,这些都要考虑到。
import time import requests import pandas as pd from sqlalchemy import create_engine from sqlalchemy.dialects.postgresql import insert def fetch_minute_data(symbol, start_date, end_date, max_retries=3): """拉取单只股票的分钟数据,带重试机制""" for attempt in range(max_retries): try: resp = requests.get( API_URL, params={"symbol": symbol, "start": start_date, "end": end_date}, timeout=10 ) if resp.status_code == 200: data = resp.json() if not data: return pd.DataFrame() df = pd.DataFrame(data) # 字段重命名和类型转换 df = df.rename(columns=FIELD_MAPPING) df["trade_time"] = pd.to_datetime(df["trade_time"]) df["volume"] = df["volume"].astype(int) return df elif resp.status_code == 429: # 被限流,等待后重试 time.sleep(2 ** attempt) else: time.sleep(1) except requests.exceptions.RequestException: time.sleep(2 ** attempt) return pd.DataFrame()这个函数里,2 ** attempt是退避策略,第一次失败等1秒,第二次等2秒,第三次等4秒。这样既能避免频繁请求被彻底封禁,又能保证在临时故障后自动恢复。
调度方面,我用APScheduler做定时任务。分钟数据在每天15:30触发一次全量补采,Tick数据在盘中9:30到15:00之间每5秒触发一次增量拉取。注意,APScheduler的时区要显式设置为Asia/Shanghai,否则默认用UTC,会导致任务在错误的时间执行。
3.3 数据清洗的五个关键步骤
清洗是整条管道里最耗时的部分,但也是最不能省的。我把它拆成五个步骤,每一步都有明确的输入输出。
第一步:去重。按(symbol, trade_time)去重,保留最新的一条。重复数据通常来自采集程序的重试,或者数据源本身的重复推送。
第二步:时间对齐。把时间戳统一到分钟边界。比如9:30:15的数据,归到9:30这一分钟。集合竞价的9:25:00单独保留,不参与对齐。
第三步:异常值检测。价格超出涨跌停限制的、成交量为负的、成交额为负的,全部标记为异常。涨跌停价格的计算方法是:前收盘价乘以1.1(主板)或者1.2(创业板、科创板),四舍五入到分。注意ST股票的涨跌停是5%,这个要单独处理。
第四步:缺失值填充。如果某分钟没有数据,通常是因为停牌或者流动性太差。我的做法是不填充,直接留空。回测时遇到空值就跳过该分钟,避免引入虚假数据。
第五步:复权因子计算。从日线数据里读取除权除息信息,计算每日的复权因子,存到单独的因子表里。这一步可以每天收盘后跑一次,不需要实时计算。
def clean_minute_data(df, pre_close, limit_pct=0.1): """清洗分钟数据""" # 去重 df = df.drop_duplicates(subset=["symbol", "trade_time"], keep="last") # 时间对齐到分钟 df["trade_time"] = df["trade_time"].dt.floor("min") # 异常值检测 upper_limit = round(pre_close * (1 + limit_pct), 2) lower_limit = round(pre_close * (1 - limit_pct), 2) df = df[ (df["close"] <= upper_limit) & (df["close"] >= lower_limit) & (df["volume"] >= 0) & (df["amount"] >= 0) ] return df提示:涨跌停价格的计算要用
round而不是int,因为A股的价格最小单位是分,四舍五入的规则和Python的round略有不同。比如2.675元,Python的round(2.675, 2)结果是2.67,但实际交易所的规则是2.68。这个差异在低价股上影响不大,但在高价股上可能导致误判。稳妥的做法是用Decimal模块做精确计算。
3.4 存储优化:分区表与索引策略
分时数据量增长很快。假设全市场5000只股票,每只股票每天240条分钟数据,一天就是120万条。一年250个交易日,就是3亿条。如果不做优化,查询会越来越慢。
我的做法是按月做分区表。PostgreSQL支持声明式分区,建一张主表,然后每个月建一张子表。查询时如果带上时间范围,PostgreSQL会自动裁剪分区,只扫描相关子表。
-- 创建分区主表 CREATE TABLE minute_bars ( id BIGSERIAL, symbol VARCHAR(10) NOT NULL, trade_time TIMESTAMP NOT NULL, open NUMERIC(10,3), high NUMERIC(10,3), low NUMERIC(10,3), close NUMERIC(10,3), volume BIGINT, amount NUMERIC(20,3), pre_close NUMERIC(10,3), created_at TIMESTAMP DEFAULT NOW() ) PARTITION BY RANGE (trade_time); -- 创建月度分区 CREATE TABLE minute_bars_2024_01 PARTITION OF minute_bars FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');索引方面,(symbol, trade_time)的联合索引是必须的,它覆盖了最常见的查询模式:查某只股票某段时间的数据。另外,如果经常按时间范围查全市场数据,可以再加一个(trade_time)的单列索引。但索引不是越多越好,每个索引都会增加写入开销。我的经验是,分钟表最多建两个索引,Tick表最多建三个。
Redis用来缓存最近几个交易日的分钟数据,供实时查询使用。缓存键的设计是minute:{symbol}:{date},值是序列化后的DataFrame。缓存过期时间设为24小时,每天收盘后自动失效,第二天开盘前重新加载。
4. 常见问题与排查技巧实录
4.1 数据源接口突然返回空数据怎么办
这是最常见的问题,没有之一。表现是:程序正常运行,日志没有报错,但数据库里就是没有新数据。排查思路分三步。
第一步,确认是接口问题还是网络问题。用curl或者Postman手动调一次接口,看返回什么。如果返回空JSON或者404,说明接口变了或者挂了。如果返回超时,说明网络有问题。
第二步,检查请求参数。有些接口对日期格式很敏感,比如要求YYYY-MM-DD,你传了YYYYMMDD,它不报错,直接返回空。还有的接口对股票代码的前缀有要求,比如要加sh或者sz,不加就返回空。
第三步,看是不是被限流了。很多接口在返回空数据的同时,会在HTTP头里带一个X-RateLimit-Remaining字段。如果这个值是0,说明被限流了,需要降低请求频率或者换IP。
我遇到过一次最诡异的情况:接口在每天14:55到15:00之间返回空数据。后来发现是数据源在那段时间做收盘结算,暂停服务。解决办法是在调度里避开这个时间段,把采集任务挪到15:05之后。
4.2 时间戳重复与乱序的处理
时间戳重复通常是因为数据源在推送时做了重试,或者你的采集程序在断线重连后重复拉取了同一时间段的数据。处理方法是按(symbol, trade_time)去重,保留最新的一条。但要注意,有些数据源在重试时会推送修正后的数据,这时候保留最新的反而是对的。
时间戳乱序更麻烦。正常情况下,分钟数据应该按时间递增排列,但有些数据源返回的顺序是乱的,甚至同一分钟的数据分散在多个批次里。我的做法是在清洗阶段统一按trade_time排序,然后用drop_duplicates去重。如果发现某分钟的数据在排序后仍然重复,就要检查是不是数据源本身有问题。
注意:Tick数据的时间戳精度通常是毫秒级,但交易所的撮合系统实际精度是微秒级。如果你把Tick数据按毫秒去重,可能会丢掉同一毫秒内的多笔成交。稳妥的做法是按
(symbol, trade_time, price, volume)四元组去重,这样既能去掉重复推送,又不会误删真实成交。
4.3 回测时发现收益率异常高,怎么排查
这个问题我踩过两次坑,每次都是数据问题,不是策略问题。
第一次是成交量单位错误。数据源给的成交量是“手”,我当成“股”用了,导致计算出来的换手率只有实际的百分之一,策略误以为流动性极好,频繁交易,回测收益率虚高。排查方法是:拿一只已知成交量的股票,比如某天成交了1亿股,看你的数据里volume字段是多少。如果是100万,那就是“手”,需要乘以100。
第二次是复权因子错误。除权除息日当天,前复权价格没有及时更新,导致策略在除权日买入时,用的是除权前的价格,第二天卖出时,用的是除权后的价格,凭空多出一大截收益。排查方法是:在回测报告里单独列出除权除息日的交易记录,看价格是否连续。如果不连续,就是复权因子的问题。
下面这张表是我整理的常见问题速查表,遇到问题时可以按图索骥。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 数据库无新数据 | 接口变更/限流/网络故障 | 手动调接口,检查HTTP状态码 | 更新接口地址,降低频率,增加重试 |
| 成交量异常大/小 | 单位错误 | 用成交额除以成交量反推均价 | 统一转换为股 |
| 回测收益率虚高 | 复权因子未更新 | 检查除权除息日价格连续性 | 每日收盘后更新复权因子表 |
| 时间戳重复 | 数据源重试/采集重复 | 按主键去重 | 使用ON CONFLICT DO NOTHING |
| 某分钟数据缺失 | 停牌/流动性差/数据源问题 | 对比其他数据源 | 留空,不填充 |
| 集合竞价数据混乱 | 数据源处理方式不同 | 对比9:25和9:30的数据 | 统一保留9:25独立记录 |
4.4 性能优化:从小时级到分钟级的采集提速
最初我的采集程序是串行的,拉全市场一天的分钟数据要跑一个多小时。后来做了三个优化,把时间压缩到了8分钟以内。
优化一:并发请求。用concurrent.futures.ThreadPoolExecutor开10个线程,同时拉10只股票的数据。注意,线程数不是越多越好,太多会被数据源限流。我的经验是,根据数据源的QPS限制来定,通常5到10个线程比较稳妥。
优化二:批量写入。不要每拉一只股票就写一次数据库,而是攒够1000条或者每10秒批量写一次。PostgreSQL的execute_values比逐条INSERT快几十倍。
优化三:增量采集。不要每次都拉全量数据,而是记录上次采集的时间点,只拉新增的部分。这样每天的采集量从120万条降到几万条,速度自然快。
from concurrent.futures import ThreadPoolExecutor, as_completed def batch_fetch(symbols, start_date, end_date, max_workers=8): """并发拉取多只股票的数据""" results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(fetch_minute_data, sym, start_date, end_date): sym for sym in symbols } for future in as_completed(futures): sym = futures[future] try: df = future.result() if not df.empty: results.append(df) except Exception as e: print(f"{sym} 拉取失败: {e}") return pd.concat(results, ignore_index=True) if results else pd.DataFrame()提示:并发采集时一定要注意数据源的限流策略。有些接口是按IP限流的,你开再多线程也没用;有些是按账号限流的,多线程反而会触发封禁。最好的做法是先小规模测试,找到数据源的实际QPS上限,再设置线程数。
5. 从数据管道到策略应用:几个实战心得
数据管道搭好之后,真正的挑战才开始。我最初以为只要数据干净,策略回测就是水到渠成的事,结果发现分时数据的应用场景和日线完全不同,很多在日线上有效的逻辑,搬到分时上就失效了。
第一个心得是:分时数据的信噪比极低。日线数据一天一个点,趋势相对清晰;分时数据一天240个点,大部分是噪音。如果你直接把日线的均线策略搬到分时上,会发现交易频率极高,手续费吃掉大部分收益。我的做法是在分时数据上先做降噪处理,比如用5分钟或者15分钟的聚合数据来做信号,再用1分钟数据来执行。
第二个心得是:成交量在分时级别比价格更重要。日线策略通常关注价格形态,但分时级别的价格波动很多时候是随机的,真正有信息量的是成交量的异常放大。比如某只股票在某个分钟突然放量上涨,这往往意味着有资金在主动买入。我在策略里加了一个成交量突破的过滤条件,胜率明显提升。
第三个心得是:午间休市前后的数据要特别处理。11:30到13:00是休市时间,但有些数据源会把11:30的数据延续到13:00,导致出现一根长达90分钟的“假K线”。如果不处理,策略会误以为这段时间有交易,产生错误信号。我的做法是在清洗阶段把11:30之后、13:00之前的数据全部标记为无效,回测时跳过。
第四个心得是:Tick数据的存储成本远超预期。我一开始把Tick数据也存到PostgreSQL里,结果三个月就撑爆了磁盘。后来改用列式存储,把Tick数据按天存成Parquet文件,查询时用DuckDB或者Pandas直接读,速度反而更快。Parquet的压缩率很高,一天的Tick数据压缩后只有几百MB,比存数据库划算得多。
# 将Tick数据存为Parquet文件 df.to_parquet( f"/data/tick/{symbol}/{date}.parquet", engine="pyarrow", compression="snappy", index=False ) # 查询时直接读取 df = pd.read_parquet(f"/data/tick/{symbol}/{date}.parquet")这套方案跑了一年多,整体还算稳定。最大的体会是:分时数据管道不是一次性的工程,而是一个需要持续维护的系统。数据源会变,字段会变,交易所的规则也会变,每隔几个月就得回头检查一遍,看看有没有什么地方悄悄失效了。我现在的习惯是每周一早上花半小时,随机抽几只股票,把管道里的数据和券商客户端上的数据做个对比,确认没有偏差。这个习惯帮我提前发现了好几次数据源变更,避免了回测结果出现系统性偏差。
最后再分享一个小技巧:如果你也在做分时数据相关的项目,建议在管道里加一个“数据质量看板”,每天自动统计当天的数据条数、缺失率、异常值比例,用邮件或者消息推送到手机。这样即使你不主动检查,也能第一时间知道管道有没有出问题。我用的是一张简单的HTML报表,每天早上8点自动生成,花不了多少时间,但省心很多。