
先把结论放前面这篇文章要解决的问题是很多做量化、做复盘、或者单纯想给自己留一份干净行情数据的朋友都会遇到的。通达信系的软件包括申万宏源金融终端会把日线行情以二进制文件存在本地你可以在打开软件的情况下直接去读但如果想用 Python、Pandas、SQLite 做进一步分析就必须先把这些.day文件解析成结构化数据。这篇文章就以申万宏源金融终端默认数据目录C:/zd_swhy_gm/vipdoc/sh/lday下的sh600000.day文件为例把从二进制解析到存入 SQLite3 的完整过程讲清楚代码可以直接跑路径改一改就能用于其他股票。这篇文章适合两类人一类是刚接触量化、想用本地行情数据做回测的初学者另一类是已经在用行情软件但受不了每次手工导出 Excel 的操作型选手。读完你不仅能搞懂.day文件的二进制结构还能拿到一套可复用的解析入库脚本以后每天收盘后自动更新数据也不是难事。1. 先搞懂申万宏源金融终端的本地数据目录1.1 整条路径逐个拆开看先看这个路径C:/zd_swhy_gm/vipdoc/sh/lday/sh600000.day我第一次看到这个目录的时候也懵了一下zd_swhy_gm这串字符怎么看都不像券商名字。后来才明白zd大概率是通达信内核早期版本留下的命名痕迹swhy_gm才是申万宏源的拼音缩写。不同券商的通达信定制版安装根目录名字长得都不一样比如有的叫zd_cjzq有的叫zd_zszq但核心结构基本一致vipdoc这个名字是通用的。再说vipdoc它是通达信系软件的数据仓库目录日线、分钟线、分笔、财务数据都放在这下面。vipdoc里一般还能看到cw财务数据、fzline分笔成交、minline分钟线等子目录这篇文章只关注lday也就是日线数据。sh表示上海市场sz表示深圳市场bj表示北交所。如果你把sh目录和sz目录分别打开对比会发现一个规律sh目录下是sh600000.day、sh601398.day这种命名sz目录下则是sz000001.day、sz300750.day这种命名。文件名的前两位正是交易所缩写中间是股票代码后缀.day明确告诉你这是日线文件。1.2 lday目录里到底有什么文件打开lday目录后你大概率会看到几百上千个文件每个文件对应一只股票或者一个指数。比如sh600000.day是浦发银行的日线sh000001.day是上证指数sz399001.day是深证成指。这里有一个容易混淆的点sh000001是上证指数不是股票但它同样以.day文件存在而且二进制结构完全一样。这些文件的大小通常不是整块的因为每只股票上市时间不同、停牌天数不同记录条数也就不一样。每条记录固定占 32 字节所以文件大小等于记录条数乘以 32。如果文件大小除以 32 有余数那就要小心了要么文件没下载完整要么这是某个定制版在文件头额外写了信息后面我会专门说这个问题。还有一个非常常见的误区直接用记事本打开.day文件看到的是乱码。别慌这不是文件坏了而是二进制存储本身就不可读。通达信为了追求读取效率和磁盘占用把日期、价格、成交量全部编码成紧凑的二进制结构人类肉眼直接读当然看不出所以然。下一节就来讲这个二进制结构到底长什么样。2..day文件的32字节二进制结构解析2.1 每条记录32字节的字段结构表通达信日线.day文件的格式在业内其实已经是被反复验证过的公开结构每一条记录固定 32 字节按照小端序排列。各字段含义如下表所示偏移量长度类型字段名说明04int日期格式如 2024010244int开盘价实际价格乘以 10084int最高价实际价格乘以 100124int最低价实际价格乘以 100164int收盘价实际价格乘以 100204float成交额单位元244int成交量单位股284int保留字段通常为 0用struct视角来看这一条记录的格式可以写成IIIIIfII。其中表示小端序I是 4 字节无符号整数f是 4 字节单精度浮点数。16 字节的价格字段加上 4 字节成交额、4 字节成交量、4 字节保留字段再加上 4 字节日期正好 32 字节。说句题外话很多人在第一步就卡住是因为不知道价格要除以 100。文件里存的不是10.50而是1050这是通达信为了用整数运算提高效率故意的设计。如果你直接把1050当价格用后面所有计算都会差两个数量级。2.2 字节序、价格缩放与单位换算为什么强调小端序因为如果按大端序去解包读出来的日期会变成完全不一样的天文数字。x86 和 ARM 处理器基本都是小端序open(rb)读出来的字节序就是文件里的原始字节序因此 Python 的struct解包时必须显式加上否则在某些平台上默认使用本机字节序又可能出幺蛾子。价格缩放很简单整数除以 100 就是正常价格。但成交量和成交额的单位问题值得专门拿出来说。文件里的成交量字段单位是“股”而通达信行情软件界面默认显示的是“手”1 手等于 100 股。所以你在软件里看到123456手文件里存的其实是12345600股。成交额字段单位是“元”软件里经常显示成“万元”换算就是除以 10000。我做数据入库时一般建议保留原始单位成交量存“股”成交额存“元”。这样数据库里是最原始的数据将来要 1 分钟线、分笔数据关联计算时不容易出错。如果你直接存成“手”后面遇到指数、ETF 这种特殊品种时单位口径不一致会让你算得很痛苦。2.3 一个小实验看字节怎么变成数字为了加深理解我们手工走一遍。假设文件中某条记录的起始 4 字节是E6 D6 34 01小端序下它表达的数字是0x0134D6E6也就是十进制20240102对应日期 2024 年 1 月 2 日。再往下 4 字节如果是A0 8C 0E 00小端序值是0x000E8CA0十进制953504除以 100 得到 9535.04这就是某天的开盘价。这种手工验算方法非常推荐大家试一次。它可以帮你确认两件事一是你的字节序理解是否正确二是文件里到底有没有额外的文件头。通常一个正常的.day文件第一笔记录应该是该股票上市首日或者历史最早数据的日期如果你手工解包第一笔记录发现日期是奇怪的数字就要怀疑文件头偏移了。3. 用Python解析sh600000.day实战3.1 环境准备最少依赖方案解析这种固定长度二进制Python 标准库的struct就够了不需要装任何第三方库。但如果后面想把数据塞进 Pandas 或者做批量文件处理建议还是装pandas和numpy处理效率和代码量都会改善不少。我的建议是分两层第一层用纯标准库写一个最基础的解析函数保证在任何机器上都能跑第二层用 NumPy 的复合 dtype 读取适合批量处理几千个文件时提升性能。两层都用同一套字段结构维护成本也不高。安装依赖就一条命令pip install numpy pandasSQLite3 是 Python 内置模块不需要额外安装。也就是说你只要有一个 Python 3.6 以上的环境就能完整跑通这篇文章里的全部代码。3.2 基于struct的解析代码下面这段代码是核心。它读取任意.day文件返回一个包含全部记录的列表每个元素是一个字典。为了让你看清结构我刻意没有做 Pandas 转换import struct from pathlib import Path def parse_day_file(file_path): 解析通达信/申万宏源日线.day文件 返回: list[dict]每个dict包含date/open/high/low/close/amount/volume file_path Path(file_path) record_size 32 # 固定32字节一条记录 # 小端序: , 依次: 日期I, 开I, 高I, 低I, 收I, 成交额f, 成交量I, 保留I fmt IIIIIfII records [] with open(file_path, rb) as f: data f.read() # 校验文件大小是否整除32 if len(data) % record_size ! 0: # 有些定制终端会在文件头加信息这里先做个提示具体处理见后面章节 print(f警告: 文件大小 {len(data)} 不是32的整数倍可能存在文件头) for i in range(len(data) // record_size): chunk data[i * record_size:(i 1) * record_size] date, open_price, high, low, close, amount, volume, reserved struct.unpack(fmt, chunk) records.append({ date: date, open: open_price / 100, high: high / 100, low: low / 100, close: close / 100, amount: amount, volume: volume, reserved: reserved, }) return records if __name__ __main__: df_list parse_day_file(rC:/zd_swhy_gm/vipdoc/sh/lday/sh600000.day) print(f共解析出 {len(df_list)} 条记录) for rec in df_list[:3]: print(rec)运行之后你会看到类似这样的输出共解析出 4867 条记录 {date: 20000104, open: 27.0, high: 27.6, low: 26.5, close: 26.9, amount: 64598688.0, volume: 2389144, reserved: 0} {date: 20000105, open: 27.5, high: 27.86, low: 26.91, close: 27.4, amount: 32891672.0, volume: 1264500, reserved: 0} {date: 20000106, open: 27.4, high: 27.75, low: 27.05, close: 27.75, amount: 36789520.0, volume: 1352600, reserved: 0}看到20000104这样的日期格式说明解析完全正确。这个输出只是示意实际数据以你自己文件为准。3.3 千万条数据的性能解法NumPy复合dtype如果你只是解析一只股票上面struct循环完全够用。但如果你打算把整个lday目录下几千个文件全量导入数据库纯 Python 循环加struct.unpack会慢得让你怀疑人生。这时候用 NumPy 的复合 dtype 一次性读取性能会提升一两个数量级。import numpy as np def parse_day_file_fast(file_path): 使用numpy复合dtype解析.day文件 返回: numpy.ndarray, 字段名 date/open/high/low/close/amount/volume dtype np.dtype([ (date, u4), (open, u4), (high, u4), (low, u4), (close, u4), (amount, f4), (volume, u4), (reserved, u4), ]) arr np.fromfile(file_path, dtypedtype) # 价格除以100转成正常价格 arr[open] arr[open] / 100.0 arr[high] arr[high] / 100.0 arr[low] arr[low] / 100.0 arr[close] arr[close] / 100.0 return arr这段代码看起来简短但每一行都值得解释。np.dtype里定义了每个字段的字节顺序、类型和名称u4表示小端无符号 4 字节整数f4表示小端 4 字节浮点数。np.fromfile会一次性把整个文件映射成结构化数组速度极快。需要注意一点arr[open] arr[open] / 100.0这一步在赋值时会把整数数组变成浮点数组之后你再访问 open 字段就是浮点价格。这个逻辑在生成DataFrame之前做比较合适因为 NumPy 在数组内部自动处理了 dtype 转换。用parse_day_file_fast解析同一份sh600000.day耗时通常只有struct版本的十分之一甚至更低。对于只是想认真管理本地数据的人这个方案非常友好。3.4 怎么验证解析结果是对的数据解析出来之后别急着入库先做三件事确认正确性。第一看首条和末条记录的日期。启动申万宏源金融终端按 F5 切到日 K 线看它显示的最早日期和最晚日期与脚本输出的第一行和最后一行对比。如果对得上说明文件的边界没有读偏日期字节序也正确。第二抽查某一天的价格和成交量。选一个最近的交易日用软件里的十字光标对比或者直接看软件右侧的盘口数据。这里要注意复权问题软件默认可能显示的是前复权价格而lday文件里是不复权的原始价格两者在除权除息后会不一致这是正常现象不是解析错误。第三看成交量单位。我前面强调过文件里是“股”软件里显示的是“手”。你在数据库里看到sh600000某天的 volume 是52000000软件里显示成交量为520000手两者是一致的。如果你发现差了 100 倍别怀疑文件检查一下是不是看串了单位。4. 把解析结果写入SQLite34.1 表结构设计别在第一天埋坑SQLite3 是一个单文件数据库特别适合这种“自己本地分析用”的场景。表结构的设计看似简单但字段类型、主键、索引的选择会直接决定后续查询和增量更新的体验。我的推荐建表语句如下CREATE TABLE IF NOT EXISTS day_data ( code TEXT NOT NULL, trade_date TEXT NOT NULL, open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL, amount REAL NOT NULL, volume INTEGER NOT NULL, PRIMARY KEY (code, trade_date) ) WITHOUT ROWID;这里几个设计点说一下。code字段我建议存储完整文件名前缀比如sh600000而不是只存600000。因为以后你可能把深市、沪市、北交所的数据都导入同一个表如果只存代码000001到底是平安银行还是上证指数会变得很难区分。trade_date用TEXT类型存储20240102这种 8 位字符串。长度为 8 的定长字符串在 SQLite 里比较和排序都非常快比存成整数再格式化更直观。如果你需要按日期范围查询BETWEEN 20240101 AND 20240131这种写法也能充分利用 B 树索引。主键用的是(code, trade_date)联合主键两个作用。一是保证同一只股票同一天只能有一条记录天然防重二是配合INSERT OR REPLACE实现增量更新时可以准确找到需要被覆盖的行。WITHOUT ROWID是 SQLite 的优化选项适用于主键明确的场景可以减少存储开销并让主键查询更快。如果对 SQLite 不熟悉可以先不写这一句后续数据量大了再加也可以。4.2 批量写入executemany加手动事务写入数据库时最忌讳的就是一条条INSERT每条都自动提交事务几千条数据可能卡到没脾气。正确做法是用executemany批量插入并且手动控制事务提交时机。下面是一个完整的入库代码直接接在解析函数后面即可运行import sqlite3 def import_day_file_to_sqlite(db_path, code, file_path): 将单个.day文件解析并写入SQLite arr parse_day_file_fast(file_path) conn sqlite3.connect(db_path) # 建表 conn.execute( CREATE TABLE IF NOT EXISTS day_data ( code TEXT NOT NULL, trade_date TEXT NOT NULL, open REAL NOT NULL, high REAL NOT NULL, low REAL NOT NULL, close REAL NOT NULL, amount REAL NOT NULL, volume INTEGER NOT NULL, PRIMARY KEY (code, trade_date) ) WITHOUT ROWID ) # 组装参数列表 rows [] for item in arr: # item[date]是np.uint32需要转成int再格式化成字符串 trade_date str(int(item[date])) rows.append(( code, trade_date, float(item[open]), float(item[high]), float(item[low]), float(item[close]), float(item[amount]), int(item[volume]), )) # 手动事务批量写入 conn.execute(BEGIN) conn.executemany( INSERT OR REPLACE INTO day_data (code, trade_date, open, high, low, close, amount, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?) , rows) conn.commit() conn.close() print(f{code} 导入完成共 {len(rows)} 条) if __name__ __main__: import_day_file_to_sqlite( db_pathrC:/stock_data/market.db, codesh600000, file_pathrC:/zd_swhy_gm/vipdoc/sh/lday/sh600000.day )关于INSERT OR REPLACE我再多解释一句。它的语义是如果联合主键(code, trade_date)已经存在就把旧记录整行替换掉。这个特性非常适合每天收盘后增量更新你每天跑一次脚本如果当天数据已经在库里就直接覆盖为新数据不会产生重复记录也不需要先DELETE再INSERT。4.3 入库后如何查询和二次使用数据入库只是开始后面的查询才是真正发挥价值的地方。举几个我实际最常用的 SQL 例子。查某只股票最近 10 个交易日SELECT code, trade_date, open, high, low, close, volume, amount FROM day_data WHERE code sh600000 ORDER BY trade_date DESC LIMIT 10;计算每日涨跌幅SELECT trade_date, close, close / LAG(close) OVER (ORDER BY trade_date) - 1 AS pct_chg FROM day_data WHERE code sh600000 ORDER BY trade_date;SQLite 从 3.25 版本开始支持窗口函数LAG、SUM OVER这些都能用做简单的技术指标计算足够用。如果后面要做更复杂的回测直接把这张表读出到 Pandas用pandas.to_sql也能无缝衔接。5. 实战中容易踩的坑与增量更新思路5.1 常见问题速查表这部分是我自己反复踩过之后整理的放成表格方便你排查。症状可能原因解决办法解析出来日期像乱码数字巨大字节序错误确认struct格式串使用开头所有价格都差 100 倍忘记价格除以 100解析后统一除以 100成交量比软件显示大 100 倍文件单位是“股”软件显示“手”按需求除以 100第一笔记录日期奇怪文件可能存在额外文件头检查文件大小是否整除 32尝试跳过前 N 字节价格和软件对不上软件显示的是前复权价确认lday是原始不复权数据某只股票记录条数为 0停牌期间不产生记录属正常现象交易日才有数据文件名找不到股票代码前缀或目录选错确认代码所属交易所sh/sz目录不同5.2 复权数据是怎么回事这是最容易让人误解的一个点。通达信系软件在行情界面默认显示的是前复权价格也就是把历史分红、送股、配股都折算到当前股价口径下。而你从lday目录解析出来的.day文件是交易所原始成交数据不包含任何复权处理。举例来说某股票以前价格 20 元后来 10 送 10除权后股价变成 10 元。在软件前复权视角里历史那天的价格会显示成 10 元附近但在lday文件里历史那天的价格依然记录的是 20 元。如果你拿数据库里的价格和软件界面对比自然对不上。这并不意味着解析错了。做量化回测时到底用不复权还是复权数据取决于你的策略逻辑。算收益率一般用后复权或前复权更合理做价格位置分析也有人偏好原始价格。通达信的复权因子数据藏在另外的目录里不在这次讨论范围内如果你确实需要可以先用行情软件自带的“数据导出”功能把复权日线导出来或者等后续文章专门聊复权因子的解析。5.3 增量更新每天收盘后怎么只更新新增数据全量导入一次之后最自然的需求就是以后每天只更新当天新增的一条记录。原理很简单每次入库前查一下该股票在数据库里的最大日期然后只处理比它新的记录。我给你一个精简版思路核心就是一句 SQLdef get_max_date_in_db(conn, code): cur conn.execute( SELECT MAX(trade_date) FROM day_data WHERE code ?, (code,) ) row cur.fetchone() if row and row[0]: return int(row[0]) return 0拿到max_date之后在解析出来的数组里用条件过滤只保留date max_date的记录再走INSERT OR REPLACE入库即可。这一步可以在 Python 里做也可以在 SQLite 里配合临时表做但最简单的还是解析完在内存里过滤。我建议每天收盘后定时跑一次这个脚本。至于定时任务怎么触发Windows 上用“任务计划程序”Linux 上用crontab都不是今天重点但思路是通用的。5.4 批量导入多只股票解析函数和入库函数都支持传入任意.day文件路径所以批量导入就是遍历目录的事。from pathlib import Path lday_dir Path(rC:/zd_swhy_gm/vipdoc/sh/lday) for f in lday_dir.glob(*.day): code f.stem # 文件名如 sh600000 import_day_file_to_sqlite( db_pathrC:/stock_data/market.db, codecode, file_pathstr(f) )这里有一点要注意lday目录里不仅有股票还有指数比如sh000001.day。如果你不希望指数混入股票表可以在遍历时加个过滤规则比如跳过sh000001到sh000999之间的代码或者建一张instrument表专门维护“哪些代码是股票、哪些是指数”。这些业务规则没有统一标准取决于你自己的使用场景。还有一个小细节不同券商的通达信定制版lday目录下偶尔会出现重复的副本文件比如带(1)后缀的备份文件。用glob(*.day)不会匹配到sh600000(1).day但如果你用glob(*)再手工判断后缀就要小心这种坑。最后说点我自己的体会这套解析入库的方案我从最早的struct循环一路演进到numpy复合 dtype最直观的感受是二进制解析本身并不难难点在于你愿不愿意把边界情况处理清楚。比如文件头偏移、成交量单位、复权口径每一个坑都会让你的数据在某个角落悄悄出错。所以我特别建议你在入库后加一道校验程序每天随机抽取几只股票的收盘价和软件界面比对真正稳定跑上一两周这套数据管道才算靠谱。另外sh600000.day只是起点。一旦有了 SQLite 里的日线表你后面可以做均线策略回测、做全市场选股扫描、甚至可以自己写一个小型行情分析网站。这些扩展都不需要再依赖任何收费接口数据完全来自本地文件干净、可控、免费。这也是我坚持折腾本地数据而不是直接买现成数据服务的原因。