做A股量化的人,迟早都会遇到一个问题:去哪里搞一份靠谱、完整、还能离线跑的历史行情数据。我刚开始做Python量化交易的时候,也迷信各种在线数据API,后来发现限频、缺字段、历史深度不够都是常态,最麻烦的是每次回测都要联网,策略还没跑明白,数据源先挂了。后来我开始研究通达信本地生成的.day文件,再用pytdx这个库配合着做解析和读取,整个数据链路一下就顺了。
如果你是做A股日线级别策略、又不想被各种付费API绑住手脚,这篇文章应该能帮到你。我会把.day文件的二进制格式、pytdx的用法、完整解析代码、以及我在实际操作中踩过的坑一次性讲清楚,基本上看完就能直接抄作业。
1. 为什么本地.day文件才是回测的最高性价比数据源
1.1 在线API看着方便,用起来全是坑
先聊聊我为什么放着现成的在线数据不用,非得去折腾本地文件。很多人觉得,现在数据API那么多,调个接口不就行了?实际上等你真开始做策略回测,问题就一个接一个冒出来。
第一是限频。免费的数据API大多有每分钟请求次数限制,全市场5000多只股票,你批量拉日线,拉不了几十只就被限流了。第二是历史深度。很多免费接口只提供最近两三年数据,做长周期回测根本不够看。第三是字段口径不统一。有的接口返回前复权,有的返回不复权,有的甚至前后对不上,拿来做回测,结果跟实际交易完全不是一回事。
我印象最深的一次,某在线接口在盘中拉数据时居然返回了空的成交量字段,导致策略信号突然失效。从那次以后,我就养成了一个习惯:任何策略回测前,先把需要的历史行情落盘到本地,用本地数据跑通以后再谈别的。而在本地数据源里,通达信收盘后自动生成的.day文件,是最容易获取、覆盖率也最高的选择。
1.2 day文件到底是什么,去哪找
通达信软件每天收盘后,会把当天全部A股的日K线数据写进本地文件,这就是.day文件。它存放在安装目录下的vipdoc文件夹里,按市场分成sh和sz两个子目录,再往下是lday目录。
具体路径大概是这样的:
通达信安装目录/vipdoc/sh/lday/sh600000.day 通达信安装目录/vipdoc/sz/lday/sz000001.day文件命名的规则很简单:市场前缀加六位股票代码。sh代表上交所,sz代表深交所。比如sh600000.day就是浦发银行的日线数据,sz000001.day就是平安银行的日线数据。要注意的是,通达信不同版本目录结构可能略有差异,但vipdoc这个核心目录基本都在,只要你的软件正常用过,本地一定会留下这些文件。
这个目录的价值在于:它是通达信官方客户端每天自动更新的,你不用额外写爬虫、不用付费买接口,只需要定期把解析逻辑跑一遍,就能拿到一份覆盖全市场的历史日线数据,而且这些数据完全离线可用。
2. pytdx库能干什么:实时行情和本地解析一把抓
2.1 安装和基础连接方式
pytdx是Python连接通达信协议的一个三方库,虽然网上很多人只拿它来拉实时行情,但实际上它也内置了本地数据解析的reader模块。安装非常简单:
pip install pytdx安装完以后,可以先连接一个通达信行情服务器,验证一下库能不能正常工作。这里用标准的TdxHq_API来连接:
from pytdx.hq import TdxHq_API api = TdxHq_API() with api.connect('119.147.212.81', 7709): # 类别传9表示日线,市场0表示上证,600000是股票代码 # 这里拉浦发银行最近10根日K线 bars = api.get_security_bars(9, 0, '600000', 0, 10) df = api.to_df(bars) print(df.head())connect的时候需要填行情服务器的IP和端口,不同服务器的连通性会有一点差别,建议多试几个,选响应快的用。这个连接过程走的是通达信的公开行情协议,和我们日常看行情软件是一样的,没有额外的门槛。
2.2 在线拉取日线数据,和.day文件做对照
用上面的代码拉下来的数据,字段大概是这样的:
year month day open close high low vol amount 2023 12 1 9.50 9.60 9.65 9.45 123456.0 1.18e+09 2023 12 4 9.58 9.55 9.62 9.50 110230.0 1.05e+09可以看到,在线接口返回的字段里,open、close、high、low已经是我们熟悉的股票价格了,vol是成交量,amount是成交额。这些数据可以作为解析.day文件之后的对照参考,后面会用到。
不过这里要提醒一句:在线接口返回的日线数据,默认是不复权的。如果你在通达信软件里看的是前复权K线,那和这里的结果会不一样。这个点非常关键,后面讲坑的时候会展开。
3. .day文件的二进制格式拆解:一条记录32字节
3.1 文件内容就是定长记录的堆叠
.day文件本身不是文本文件,而是一个二进制文件。它没有任何文件头,就是一条一条定长记录从头排到尾。每一条记录固定32字节,对应一根日K线。也就是说,你只要知道这个文件里有多少条记录,就可以直接计算有多少根K线。
这32字节的排列方式如下:
| 偏移 | 字节长度 | 字段 | 类型 | 说明 |
|---|---|---|---|---|
| 0 | 4 | 日期 | uint32 | 格式为YYYYMMDD,例如20231201 |
| 4 | 4 | 开盘价 | uint32 | 实际价格需要除以100 |
| 8 | 4 | 最高价 | uint32 | 实际价格需要除以100 |
| 12 | 4 | 最低价 | uint32 | 实际价格需要除以100 |
| 16 | 4 | 收盘价 | uint32 | 实际价格需要除以100 |
| 20 | 4 | 成交额 | float32 | 单位通常是元 |
| 24 | 4 | 成交量 | uint32 | 需要除以100,单位通常是手 |
| 28 | 4 | 保留字段 | uint32 | 一般为0,可忽略 |
这个结构和C语言里的结构体非常像,用Python解析的时候,最直接的方式就是用struct库按格式拆包。你可以理解成:整份.day文件就是一张只有一行一行数字的表,每一行32个字节,解析的过程就是把这32个字节按规则切成8个字段。
3.2 几个关键细节:乘100、整数日期、量额单位
第一次看这个格式,很多人会懵:为什么价格要乘100存?为什么不直接存浮点数?
这是早期行情协议的设计习惯。用整数存储价格,可以避免浮点数在比较和存储过程中的精度问题,而且int32的最高价、最低价也能表示到足够精度。比如收盘价9.6元,文件里存的是960,解析的时候除以100就得到9.6元。这个处理很简单,但一旦忘记除100,回测结果会非常离谱,价格凭空多了两位小数。
日期字段同样不是常见的时间戳,而是直接存成YYYYMMDD的整数。比如2023年12月1日,文件里就是一个整数20231201。转成Python的datetime对象也不复杂,先把整数转成字符串,再按格式解析就行。
成交量和成交额也要特别注意。成交量在.day文件里存的原始值,通常需要除以100才是股票软件里显示的手数。但这里有一个坑:不同券商版本的通达信,量额单位可能存在差异。所以我强烈建议:第一次写完解析代码,别急着跑全市场,先拿一只股票最近20个交易日的解析结果,和通达信软件界面上的数字逐项核对一遍,确认单位没问题了再批量处理。
4. 完整实战:两种方式解析通达信.day文件
4.1 方式一:手写struct解析,把原理钉死
先来手写一个完整的解析函数。这种方式的好处是能让你彻底理解.day文件的格式,出现问题也知道从哪排查。
import struct from pathlib import Path from datetime import datetime def parse_day_file(filepath): """ 手写解析通达信.day文件 返回一个列表,每个元素是一个dict,包含: date, open, high, low, close, amount, volume """ data = Path(filepath).read_bytes() # 每条记录32字节 record_size = 32 # 格式化字符串的含义: # < 表示小端序 # I 表示无符号32位整数 # f 表示32位浮点数 # 一共8个字段,正好32字节 unpack_format = '<IIIIIfII' bars = [] for offset in range(0, len(data), record_size): record = data[offset:offset + record_size] if len(record) < record_size: break date_int, open_int, high_int, low_int, close_int, amount, volume, reserved = \ struct.unpack(unpack_format, record) date_str = str(date_int) # 日期格式是YYYYMMDD,转成datetime dt = datetime.strptime(date_str, '%Y%m%d') bars.append({ 'date': dt, 'open': open_int / 100.0, 'high': high_int / 100.0, 'low': low_int / 100.0, 'close': close_int / 100.0, 'amount': amount, 'volume': volume / 100.0, }) return bars if __name__ == '__main__': bars = parse_day_file(r'C:\new_tdx\vipdoc\sh\lday\sh600000.day') for bar in bars[-5:]: print(bar)这段代码跑出来的结果类似这样:
{'date': datetime.datetime(2023, 12, 1, 0, 0), 'open': 9.5, 'high': 9.65, 'low': 9.45, 'close': 9.6, 'amount': 1180000000.0, 'volume': 1234.56}手写struct的优点是完全不依赖库,哪怕换个环境、不装pytdx也能跑。缺点是需要自己处理日期转换、字段命名、单位换算这些细节。如果你只是想快速拿到数据跑策略,这种方式稍微有点繁琐。
4.2 方式二:pytdx.reader一行读取,日常首选
其实pytdx本身自带了reader模块,专门用来解析通达信本地数据文件。用它的TdxDayBarReader,一行代码就能读完一个.day文件:
from pytdx.reader import TdxDayBarReader filepath = r'C:\new_tdx\vipdoc\sh\lday\sh600000.day' # 方式1:拿到bar对象列表 bars = TdxDayBarReader().get_bars(filepath) # 方式2:直接拿到pandas DataFrame df = TdxDayBarReader().get_df(filepath) print(df.head())这里get_df返回的DataFrame,列名和通达信软件里的字段对应关系如下:
year month day open close high low vol amount 2023 12 1 9.50 9.60 9.65 9.45 1234.56 1.18e+09可以看到,pytdx.reader已经帮我们处理了价格除以100、日期拆分成year、month、day这些事,用起来非常省心。对大部分日常分析场景来说,直接用它就够了。
提示:pytdx.reader返回的Bar对象里,年份、月份、日期是拆开的,使用的时候需要自己拼成datetime索引。如果需要把日期作为索引,建议用pd.to_datetime拼一下。
4.3 两种方式对比和性能选型
我整理了一张对比表,方便你在实际项目里做选择:
| 对比项 | 手写struct解析 | pytdx.reader解析 |
|---|---|---|
| 依赖 | 只需Python标准库 | 需要安装pytdx |
| 代码量 | 约30行 | 1-2行 |
| 可读性 | 格式转换逻辑清晰 | 简洁但掩盖细节 |
| 性能 | 单文件很快,批量一般 | 单文件很快,批量效率更高 |
| 适合场景 | 学习原理、排查格式问题 | 日常分析、策略回测 |
如果你的项目要一次性处理全市场几千个.day文件,手写struct循环逐条unpack会稍微吃力,这时有两个优化思路。
第一个思路是用pytdx.reader,它内部做了批量解析优化。第二个思路是用numpy一次把整个文件读成数组,再按列处理。numpy的方式我简单展示一下:
import numpy as np def parse_day_numpy(filepath): # 用np.fromfile一次性读取所有数据 raw = np.fromfile(filepath, dtype='<u4') # 每8个uint32为一组,正好是32字节一条记录 raw = raw.reshape(-1, 8) dates = raw[:, 0].astype(str) open_prices = raw[:, 1] / 100.0 high_prices = raw[:, 2] / 100.0 low_prices = raw[:, 3] / 100.0 close_prices = raw[:, 4] / 100.0 # 注意第5列是float32,需要单独读 # 实际生产中用np.memmap会更省内存 return dates, open_prices, high_prices, low_prices, close_prices这种方式在数据量极大的时候优势很明显,几千只股票的处理时间能从分钟级压到秒级。不过日常用pytdx.reader已经完全够快了,我一般建议先从pytdx.reader入手,等真遇到性能瓶颈再上numpy优化。
5. 解析过程中最容易踩的坑:一次说清
5.1 数据对不上,先查复权和单位
最常见的问题就是:解析出来的数据和通达信软件上显示的K线对不上。这时候先别怀疑解析代码,大概率是复权状态不一致。
.day文件里存的是不复权原始价格。通达信软件默认显示的是前复权价格,尤其是除权除息日之后,两者价格会差很多。如果你拿.day文件的数据去和软件默认界面比对,日期越久差别越大,因为前复权会不断把历史价格往下调整。
我在实际项目里,把本地.day文件作为“不复权底稿”,需要前复权或后复权的数据时,再结合股票的除权除息数据来计算。这样做的可控性最强。用pytdx在线接口拉日线时,同样要确认接口给你的是不复权还是复权后的数据,不同版本默认值不一样。
单位问题也一样容易踩。价格除以100这个逻辑大家都记得,但volume乘不乘100就很容易漏。所以我在4.3节里强调过,第一次写完解析,务必拿软件界面逐项核对20个交易日左右的数据,把价格、成交量、成交额全部对齐以后,再批量入库。
5.2 文件路径和读取环境的坑
第二类问题集中在文件路径上。很多人的通达信安装路径里带空格或者中文,直接用原始路径没问题,但如果你把路径写死在代码里,换一台电脑就要改一次。建议用pathlib来处理路径,或者做一个配置文件统一管理数据目录。
还有一个小坑:通达信如果在运行中,某些文件可能会被占用,导致读取时出现权限错误。我遇到过几次读取到一半报错的情况,后来总结出的经验是:先从vipdoc目录把需要的.day文件复制到一个临时目录,再从临时目录解析。文件数量不大,复制成本极低,但能避免很多莫名其妙的问题。
另外,不是所有股票都有对应的.day文件。新股上市当天可能还没落盘,退市股票的数据可能被清理,停牌时间特别长的股票文件记录数也会偏少。批量处理时,文件不存在或者文件为空都不要直接报错,打印一条日志跳过,让整个流程跑完。
5.3 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 日期解析出来是负数或乱码 | 用了有符号整数,或者字节序错误 | 使用struct的<I格式,保证小端序 |
| 价格比软件显示多了两位小数 | 忘记除以100 | 开高低收全部除以100 |
| 成交量比软件显示大了100倍 | 成交量单位没有处理 | volume除以100,再和软件核对 |
| 读不到文件 | 路径不对,或行情目录结构不同 | 检查是否存在vipdoc/sh/lday结构 |
| 数据只有最近几天 | 通达信软件没有开通全部历史数据 | 在通达信里做一次盘后数据下载 |
| 数据日期和软件对不上 | 复权状态不一致 | 明确day文件是不复权数据 |
| 批量处理时中途报错 | 个别文件为空或损坏 | 加异常处理,打印日志跳过 |
| 用get_df方法报错 | pytdx版本太老 | 执行pip install --upgrade pytdx |
注意:如果你发现解析出来的历史数据起始时间早于预期,先检查一下通达信的数据下载设置。有些版本默认只保留最近几年的日线,需要在软件里手动执行一次“盘后数据下载”,把完整历史补齐。
6. 数据解析完不算完:增量更新、入库与回测衔接
6.1 每天收盘后的增量更新流程
.day文件是通达信每天收盘后自动更新的,所以你的解析任务最好也做成增量式的,每天只需处理新增的那一天数据,不用每天全量重跑。
我的做法是:每天收盘后跑一个定时任务,先从vipdoc目录把当天更新的.day文件复制到工作目录,然后解析最近两根K线(防止昨天因为节假日或停牌漏更新),追加到历史数据表里。追加之前先按日期去重,避免重复插入。
如果你不想依赖本地方言文件的更新,也可以用pytdx在线拉当日日线来补充:
from datetime import datetime from pytdx.hq import TdxHq_API api = TdxHq_API() with api.connect('119.147.212.81', 7709): # 拉最近5根日线 bars = api.get_security_bars(9, 0, '600000', 0, 5) df = api.to_df(bars) today = datetime.now().date() today_bar = df[df.apply(lambda r: datetime(r['year'], r['month'], r['day']).date(), axis=1) == today] print(today_bar)这样即使你忘记了拷贝.day文件,在线接口也能帮你把当天的数据补齐。
6.2 全市场批量读取与存储建议
做全市场策略时,你需要把sh和sz两个目录下所有.day文件都读出来。这一步建议用pytdx.reader配合全局遍历:
from pathlib import Path from pytdx.reader import TdxDayBarReader vipdoc_dir = Path(r'C:\new_tdx\vipdoc') output_dir = Path(r'D:\market_data') output_dir.mkdir(exist_ok=True) # 遍历所有市场的lday目录 for lday_dir in vipdoc_dir.glob('*/lday'): for day_file in lday_dir.glob('*.day'): try: code = day_file.stem[2:] # 去掉sh/sz前缀 df = TdxDayBarReader().get_df(str(day_file)) if df.empty: continue # 生成日期索引 df['datetime'] = pd.to_datetime( df['year'].astype(str) + '-' + df['month'].astype(str) + '-' + df['day'].astype(str) ) df = df.set_index('datetime') # 按股票代码保存,建议用parquet格式 df.to_parquet(output_dir / f'{code}.parquet') except Exception as e: print(f'解析 {day_file} 失败: {e}')存储格式上,我的建议是优先用parquet,不要直接用CSV。CSV占用空间大,读起来也慢;parquet是列式存储,几百M的数据也能秒读,还能保留数据类型和索引,后续做回测会舒服很多。如果你的环境不支持parquet,退而求其次可以用HDF5或者SQLite,尽量不要裸存CSV。
6.3 和回测框架衔接的一点实践经验
数据解析完,最终目的是喂给回测框架用。无论你用backtrader、vnpy还是自己写的回测引擎,有几个点一定处理到位。
第一是索引对齐。回测框架通常要求时间索引是datetime类型,而且最好按升序排列。从.day文件解析出来的数据本身是按日期递增的,但经过合并、更新后可能会乱序,入库前做一个sort_index很必要。
第二是复权处理。如果你的策略涉及除权除息日附近的信号,不复权数据会给出错误信号。建议在入库前就把复权问题想清楚:要么统一用前复权,要么在策略层面对除权除息做处理,最忌讳的是今天用不复权、明天用前复权,口径来回变。
第三是别把成交量单位弄混。有的框架默认成交量单位是股,有的默认是手。你在入库时如果做了除100处理,就要确保框架也是这么理解的,否则最终盈亏数据会错得很离谱。我之前就因为单位问题,跑出来的策略年化收益虚高了大几十个点,那会儿还以为是策略厉害,后来一查,纯粹是数据口径问题。
最后说一点我自己的习惯:每次解析完一批.day文件,我会随机抽两三只股票,把最近20天的K线和通达信软件显示的一一核对一遍,确认单位、复权、日期都没有偏差再入库。这个习惯帮我挡掉了好几次数据口径翻车的风险。希望大家拿着这份代码,也能先把数据对齐这一步做扎实。