最早我跟通达信“较劲”是在一次量化复盘的需求里:手里有几百只股票,想拿历史行情做回测,结果发现通达信自带的数据导出功能点起来太折磨人,不是得一只只选,就是导出后要么缺字段、要么格式还得二次清洗。后来我索性不再走软件的导出菜单,直接去读通达信安装目录下的本地数据文件。绕开界面操作之后,整个流程变成了一个可重复的执行脚本,数据转换工具也就在这时候成型了。
如果你也需要把通达信的本地数据转成自己熟悉的格式,比如 CSV、Excel 或者 DataFrame,这篇文章刚好可以帮你少走弯路。我会把目录结构、二进制文件格式、转换工具的实现思路、以及我在实际使用中踩过的几个大坑一次性说完,适合做量化分析、数据清洗或者技术指标二次开发的读者参考。
1. 本地数据不在你想的地方:先搞清楚通达信的文件目录
很多人在通达信里找数据,第一反应是打开行情软件按 F1 或右键菜单“数据导出”。但真要批量、稳定地读数据,最靠谱的来源反而是软件安装目录下面那些不起眼的数据文件。通达信把行情数据存在一个叫 vipdoc 的目录里,旁边还有一个 T0002 目录,主要放自选股、板块和用户配置。vipdoc 内部按市场再分目录,sh 代表上海市场,sz 代表深圳市场。
我在转换工具里最常用的路径大致如下:
| 相对 vipdoc 的路径 | 内容 | 常见扩展名 |
|---|---|---|
| sh/lday | 沪市日线数据 | .day |
| sz/lday | 深市日线数据 | .day |
| sh/fzline | 沪市5分钟K线 | .lc5 |
| sz/fzline | 深市5分钟K线 | .lc5 |
| sh/minline | 沪市1分钟K线 | .lc1 |
| sz/minline | 深市1分钟K线 | .lc1 |
文件命名规则也很有意思,它不是简单的“股票代码 . 后缀”,而是带市场前缀,比如 sh600000.day、sz000001.day。这个看起来不起眼的细节,在写批量转换工具时非常关键:如果光拿六位数字做唯一标识,可能会把沪深两市的同号股票混在一起。做数据工具的第一件事,就是要把“市场前缀+代码”作为一个完整的股票标识来对待。
为什么我坚持直接读本地文件,而不是调用行情接口或者依赖手工导出?原因有三点。
第一是稳定。本地数据是行情软件自己下载并落盘的二进制文件,只要文件存在,读取结果就很稳定,不受网络波动影响。第二是完整。接口通常只能取到有限长度或者有频率限制,而本地日线文件里往往存着这只股票从上市以来到最近交易日的每一根K线,对回测来说非常重要。第三是快。本地读取本质上是顺序扫描二进制文件,比逐条调接口快几个数量级。后面我会专门算一下这个速度量级。
2. 二进制记录的一眼化解析:32字节读出一根K线
通达信的本地数据文件,说穿了就是一组定长二进制记录的串联。没有表头,没有索引,每条记录固定 32 字节。找到这个规律后,解析工作就变得非常简单。
2.1 日线 .day 文件记录结构
日线文件的核心段可以拆成这样的字段表:
| 字节偏移 | 字段 | 类型 | 说明 |
|---|---|---|---|
| 0-3 | 日期 | uint32 | 存储为 20240506 这样直接可读的数字 |
| 4-7 | 开盘价 | float32 | 单精度浮点,单位元 |
| 8-11 | 最高价 | float32 | 单精度浮点,单位元 |
| 12-15 | 最低价 | float32 | 单精度浮点,单位元 |
| 16-19 | 收盘价 | float32 | 单精度浮点,单位元 |
| 20-23 | 成交额 | float32 | 单位元 |
| 24-27 | 成交量 | uint32 | 单位股 |
| 28-31 | 保留字段 | uint32 | 一般不用 |
这里有个非常容易踩的坑:价格和成交额是浮点数,不是整数。我最早写解析时图省事,把 32 字节全部按照 uint32 解读,结果读出来的开盘价、收盘价完全不对。用 Python 的 struct 模块,按小端序把这些字段拆开就能得到正确结果。
import pathlib import struct import pandas as pd def read_tdx_day(path: pathlib.Path) -> pd.DataFrame: rows = [] with open(path, "rb") as f: while True: chunk = f.read(32) if len(chunk) < 32: break date_, open_, high_, low_, close_, amount_, vol_, _ = struct.unpack( "<IfffffII", chunk ) rows.append((date_, open_, high_, low_, close_, amount_, vol_)) df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "amount", "volume"]) df["date"] = pd.to_datetime(df["date"].astype(str), format="%Y%m%d") return df这段代码的核心就一行:struct.unpack("<IfffffII", chunk)。<表示小端序,I是无符号整型,f是单精度浮点,整体长度正好 41 + 45 + 4*2 = 32 字节,和记录长度完全对应。按这个解析出来的成交量单位是“股”,不是“手”。如果通达信界面显示某天成交 10000 手,二进制里读到的就是 1000000 股,这个换算关系在后续对账里要格外注意。
2.2 分钟线结构与文本导出的差异
日线之外,分钟线文件同样令人感兴趣。5分钟线文件扩展名是 .lc5,1分钟线是 .lc1,它们同样是 32 字节定长记录。常见的字段顺序是日期、时间、开盘、最高、最低、收盘、成交量、成交额。时间字段一般存成类似 931 这样的整数,表示 9 点 31 分,有的是 900 表示开盘第一分钟。
这里我必须多说一句:不同版本的行情软件、不同数据供应商生成的分钟线文件,字段顺序和单位可能会有一点点区别。网上有人把成交量放前面,有人把成交额放前面,相差一个字段就会导致整列数据错位。我的做法是先拿一只比较熟悉的股票做对照,读取最近两个 5 分钟K线,跟盘中看到的实时数据比对一下,确认字段顺序后再批量转换。
如果你更喜欢导出的文本格式,也有两个本地化的坑要处理。通达信导出的CSV很多时候是 GBK 编码,直接用 pandas 的默认 utf-8 读取会出现中文乱码;而二进制文件本身没有编码问题,这也是我最终选择直接解析二进制的重要原因。
2.3 为什么能做到“秒开”几千个文件
算一笔账就清楚了。一只股票 20 年日线,按每年约 250 个交易日计算,总共 5000 根K线,每条记录 32 字节,文件大小约为 160KB。全市场就算取 5000 只股票,日线文件合计也就几百 MB 级别,这对现代计算机来说完全是可接受的数据量。用 Python 顺序扫描加 struct 解包,跑完全市场日线也就是几十秒的事,比逐只调用网络接口快了太多。这也是整个数据转换工具敢于选择“直接读本地文件”这个思路的根本原因。
3. 转换工具的核心实现:扫描目录、规整字段、落盘
解析单只股票只是验证思路,真正实用的工具必须能扫描整个 vipdoc 目录,批量处理所有股票,并把结果统一落盘。这个环节我把它拆成三块:命令行入口、批量处理逻辑、输出格式。
3.1 命令行入口设计
为了让自己在日常使用时少打很多重复命令,我设计了一个简单的命令行入口,大概长这样:
python tdx2csv.py --src "D:\new_tdx\vipdoc" --out "./data" --markets sh sz --types day参数含义很直白:--src指向通达信安装目录下的 vipdoc,--out是转换结果输出目录,--markets控制扫描沪深哪个市场,--types控制转日线还是分钟线。
命令行设计成显式传参,是为了避免把安装路径硬编码在脚本里。不同人的通达信安装位置千差万别,硬盘盘符、目录名都不同,一个能反复使用的转换工具,首先要做到路径可配置。
3.2 批量扫描与解析逻辑
批量扫描的核心逻辑不复杂,但要注意几个小细节。第一步是用 pathlib 遍历指定目录下的所有 .day、.lc5、.lc1 文件;第二步是按文件名提取市场前缀和股票代码;第三步是逐文件解析成 DataFrame,顺手做一次空数据过滤;第四步是统一输出。
我当时是这么写的:
from pathlib import Path from concurrent.futures import ThreadPoolExecutor def scan_and_convert(src: Path, out_dir: Path, markets: list[str], types: list[str]): out_dir.mkdir(parents=True, exist_ok=True) files = [] for market in markets: for t in types: sub = {"day": "lday", "lc5": "fzline", "lc1": "minline"}[t] files.extend((src / market / sub).glob(f"*.{t}")) def work(path: Path): try: df = read_tdx_day(path) if path.suffix == ".day" else read_tdx_minute(path) if df is None or df.empty: return None code = path.stem df.insert(0, "code", code) out_path = out_dir / f"{code}.csv" df.to_csv(out_path, index=False, encoding="utf-8-sig") return (code, len(df)) except Exception as exc: return (str(path), exc) with ThreadPoolExecutor(max_workers=8) as pool: results = list(pool.map(work, files))这里用了 ThreadPoolExecutor 做并发,因为文件读取是个 IO 密集操作,多线程能明显缩短全市场转换时间。编码用了utf-8-sig,这是为了让生成的 CSV 文件用 Excel 直接打开时中文不乱码,也算是一个很小的经验细节。
3.3 输出格式选择与增量转换
输出格式上,我最有感触的一点是不要无脑全都导出 Excel。Excel 单个工作表最多 1048576 行,听上去很多,但如果你把全市场日线合到一个文件里,几万行倒是没问题,可一旦把分钟线也合进去,几百万行就会直接超出上限。更合理的方案是:
- 单只股票、少量数据,用 Excel 方便人工查看;
- 全市场日线,用 CSV 按代码拆分,每个文件只装一只股票;
- 如果要继续做量化分析,优先输出 Parquet 或者直接 pickle 成 DataFrame。
另外建议给工具加一个“跳过已转换文件”的选项。判断依据很简单:如果输出目录里已经存在同名的 CSV,而且文件大小大于 0,就默认跳过。这样每天收盘后只需要转换新增的文件,不用反复处理全部历史数据,执行时间会短很多。
4. 转换时最容易忽视的三个细节:复权、时间戳和最后一根K线
工具能跑通之后,我一度以为大功告成,结果在实际使用中接二连三发现问题。这些问题不是解析代码写错了,而是对行情数据本身的深层语义理解不到位。这里挑三个最有代表性的说。
4.1 本地数据默认是不复权数据
通达信本地 .day 文件里存的是交易所直接下发的原始价格,也就是不复权价格。遇到除权除息日,股价会出现明显的跳空。如果你直接拿这个数据去计算技术指标或者做回测,结果和你在行情软件里看到的前复权K线可能差得很远。
关键在于,前复权价格会随复权基准日的改变而改变,如果你直接在转换工具里写死一个前复权逻辑,反而会把数据弄坏。我的做法是:转换时保留原始价格不动,同时用独立的权息表去计算复权因子。如果你暂时没有权息表,至少要在输出的 CSV 文件名或字段注释里标明“未复权”,避免后面自己都分不清数据口径。
4.2 最后一根K线可能并不是完整K线
盘中或者收盘后不久去读取本地数据,最后一根K线往往是“未完成”的。比如日线文件里已经有“今天”这根K线,但它的最高、最低和收盘价格在盘中时还在不断变化。如果你直接把这个残影当成收盘数据去做策略信号,结果会和盘后静态数据差很多。
这个问题的解决办法不是删数据,而是让工具提供过滤选项。我更推荐的是做一个--drop-last参数,由用户根据自己取数时点决定是否去掉最后一根K线。更稳妥的做法是结合系统当前日期和文件修改时间做判断,如果文件最后更新时间距离当前时间很近,说明可能正在盘中,那就默认不输出最后一根。
4.3 代码重复、文件损坏与完整性校验
批量转换全市场时,最恼人的是一小撮“坏文件”。有些文件是刚下载了一半,记录长度不是 32 字节的整数倍;有些文件是某天程序异常退出,里面残留了空记录;还有些股票已经退市,但在目录里仍然保留着历史数据文件。如果脚本在遇到第一个坏文件时就抛异常,整个批量任务就前功尽弃了。
我的容错策略是:单个文件解析失败时,记录错误日志并继续处理下一个文件。等全部跑完后,先看日志里有哪些文件失败,再针对性处理。此外我还会生成一个 manifest 文件,记录每只股票的输出行数。之后和行情软件核对时,一眼就能看出哪只股票少了数据,不用把所有文件都打开检查一遍。
5. 转换结果到底准不准?用一次真实“对账”来验证
工具写完以后,最要紧的不是功能花哨,而是数据准不准。我在项目里专门花了半天做了一个对账实验,方法和结论都值得分享。
5.1 与通达信界面数据逐项比对
我选了沪市和深市各一只股票,比如 sh600000 和 sz000001,先读取它们最近 5 个交易日的日线记录,再打开通达信软件,手动记录同样的日期、开盘、最高、最低、收盘和成交量,逐项比对。比对时特别注意里两个点:
- 价格数值要精确到小数点后两位,如果出现类似 12.399999 这种浮点尾差,说明数据本身没问题,是浮点存储的正常现象;
- 成交量要确认单位。通达信界面通常会把成交量显示成“手”,而二进制文件里是“股”,比对时要把软件里的数值乘以 100 再比。
我第一次对账时就发现成交量总差一百倍,后来才反应过来单位问题。这一步如果做错,后面所有策略结果都会失真。
5.2 用转换结果快速算一个均线策略
对账通过后,我拿转换出的 DataFrame 直接算了一个简单的双均线策略,验证数据能不能无缝进入回测流程。
df["ma5"] = df["close"].rolling(5).mean() df["ma20"] = df["close"].rolling(20).mean() df["position"] = (df["ma5"] > df["ma20"]).astype(int) df["ret"] = df["close"].pct_change() df["strategy_ret"] = df["position"].shift(1) * df["ret"] df["cum"] = (1 + df["strategy_ret"]).cumprod()这里最重要的一个细节是position.shift(1)。如果不把信号往后移一天,你相当于在同一天收盘后看到了当天的信号,立刻用当天的收益结算,这在回测里叫“未来函数”。很多新手明明数据没问题,策略曲线却漂亮得不真实,基本就是这里出了错。
5.3 对账中发现的常见问题和规避方法
对账下来,除单位问题外,最常见的还有两类问题。
一类是成交额和成交量的数值差异。有的行情软件在导出文本时会把成交额换成“千元”或“万元”,但二进制里通常保留原始元数。另一类是日期字段的解析差异。二进制里是整数 20240506,有人直接当成字符串转日期没问题,但如果不小心把它当数值减一,日期就会错乱。
我建议工具里统一用pd.to_datetime(df["date"].astype(str), format="%Y%m%d")来做转换,既明确又不容易出错。对账这一步看似繁琐,但它能帮你把字段语义彻底固化下来,之后换数据源、换市场、换软件版本时都更有底气。
6. 转出来的数据还能怎么用:公式指标、板块数据与本地小模型
数据转换本身不是终点,最终目的是让这些数据在更多场景里发挥价值。从我的实践看,转换后的本地数据至少能在三个方向上继续延伸。
6.1 给通达信公式和自定义指标提供外部数据
通达信公式系统自带了很多指标,但它本质上是在软件内部取数运算。如果你在 Python 里加工出了一些特殊指标,比如经过机器学习预测的评分、清洗后的财务数据,想拿回通达信里看效果,可以考虑走通达信的“自定义数据”功能,把 CSV 文件导入后,再在公式里用对应函数引用。
这里有个小提示:外部数据导入前,一定要把股票代码格式和通达信内部代码格式对齐,特别是深市代码要不要带 sz 前缀,不同版本要求不一样。我吃过几次亏之后,都会在导入前先做一次字段重命名映射。
6.2 把转换结果作为本地训练和推理模型的底料
最近“开源本地数据推理模型”的概念比较热,很多人想用本地数据训练自己的小模型。这类工作通常需要一个干净、完整的训练集,而转换工具恰好能解决数据源头问题。你可以基于转换后的 DataFrame 构造一批特征:
- 过去 5 日和 20 日的收益率
- 近 20 日的波动率
- 当日成交量与 5 日均量的比值
- 最高价与收盘价的偏离程度
- 最近一段时间均线排列状态
然后把特征表保存成 CSV,喂给常见的轻量模型库,在本地完成训练和推理。需要特别强调的是,时间序列样本必须按照时间顺序切分训练集和验证集,不能随机打乱,否则相当于用未来信息训练模型,验证结果会严重失真。
6.3 与其它数据源按代码和时间对齐合并
转换出的行情数据还可以和基本面数据、板块数据、资金流数据做合并。合并时最稳妥的方式是用 (市场前缀+股票代码, 交易日期) 作为联合主键,先做一次字段类型统一,再按左连接或内连接合并。我用 pandas 的 merge 做过很多次类似操作,只要保证代码和日期两列格式一致,基本不会出问题。
如果你的最终目标是搭建一个可复用的本地数据管道,我建议在转换工具里就固定输出的列名顺序,并增加一个带版本号的输出目录。这样一来,后续的数据清洗、特征工程、模型训练都能基于同一套标准数据展开,不容易在迭代中搞乱。
我做这个通达信数据转换工具,最大的体会是:很多看似复杂的数据问题,本质上只是文件格式和字段语义的问题。只要花时间把目录结构、二进制布局、单位换算这些底层细节摸清楚,后续的一切都会顺畅很多。如果你也在做类似的事情,建议先别急着把所有功能做全,而是先挑一只股票,把单文件解析、对账、输出这一条链路跑通,再慢慢扩展到全市场。这样既能控制风险,也能让你在每一步都清楚自己究竟在转换什么。