资讯动态

通达信本地日线数据解析:从.day二进制到SQLite数据库

发布时间:2026/10/3 1:07:33 来源:尧图企业网站定制
做量化研究这么些年我越来越觉得数据前置比模型先进更重要。很多朋友一上来就到处找股票行情接口折腾半天才发现免费的接口要么有积分门槛、要么历史数据残缺付费接口又担心数据源不透明、不敢拿来做回测。后来我把目光转向了本地打开申万宏源金融终端通达信内核后行情数据会悄悄落在 C:\zd_swhy_gm\vipdoc 这个目录里日线、分钟线、分笔全都有。这篇文章就从 C:/zd_swhy_gm/vipdoc/sh/lday/sh600000.day 这个文件讲起手把手带你把这一个文件、以及整个目录里几百个 .day 文件解析出来转进 sqlite3 数据库后面查询、清洗、回测一步到位。先说清楚这篇不是讲怎么用现成行情软件的而是讲怎么把券商终端已经下载好的本地二进制数据变成你自己能掌控的数据资产。无论你之后是想画K线、做技术指标还是跑多因子回测一个结构化的本地数据库都是最扎实的底座。1. 为什么绕开在线接口直接吃券商终端的本地数据1.1 在线行情接口看着方便用起来全是隐性成本市面上确实有不少免费的行情接口比如常见的数据开源库、网页爬虫方案。但如果你实际拿它们做日线级别的研究很快会撞到几堵墙历史数据往往只给你最近几年想拉一只股票从上市到今天的全部日线很难请求频率稍高就触发限流有的数据源字段还不完整连成交量单位都忽股忽手地变。更关键的是数据可信度。做研究的人最怕拿到脏数据还不自知——前复权因子算错、复权价对不上、停牌日被错误填充这些问题在部分第三方接口里是真实存在的。相比之下通达信内核的券商终端日线数据是直接从行情服务器同步过来的软件界面显示什么vipdoc 目录里就是什么至少数据源一致性是有保障的。1.2 vipdoc 目录通达信家族的数据仓库通达信类软件包括申万宏源金融终端、各家券商定制版的本地数据目录结构高度统一。拿申万宏源这个版本举例默认安装在 C:\zd_swhy_gm 下核心数据都在 vipdoc 子目录里。目录存放内容vipdoc/sh/lday上海市场日线数据.dayvipdoc/sz/lday深圳市场日线数据.dayvipdoc/bj/lday北京市场日线数据.day老版本可能没有vipdoc/sh/minline上海市场分钟线数据vipdoc/sh/fzline上海市场分笔成交数据lday 这个名字其实就是local day的缩写专门存日线。里面每个文件对应一只证券文件命名规则是市场代码前缀 证券代码 .day。比如 sh600000.day 就是上海市场的浦发银行sz000001.day 就是深圳市场的平安银行。指数也在里面像 sh000001.day 就是上证指数。搞清楚这个目录结构等于拿到了整个市场的日线数据地图。你不需要敲一行爬虫代码数据就已经在硬盘上了缺的只是一个字节一个字节把它挖出来的解析工具。2. .day 文件不是文本是 32 字节一条的定长二进制记录2.1 每根日线的 8 个字段很多人第一次打开 .day 文件都会懵这玩意儿用什么文本编辑器看都是一堆乱码没错因为它压根不是给人直接看的数据。通达信的日线文件是一种非常经典的定长二进制格式没有文件头、没有分隔符从文件开头到结尾每 32 个字节就是一根K线的完整记录。这 32 个字节内部是 8 个字段布局如下偏移量字节数类型字段含义原始存储值说明04uint32日期直接存整数格式是 YYYYMMDD比如 2024062844uint32开盘价实际价格 × 100比如 7.25 元存为 72584uint32最高价实际价格 × 100124uint32最低价实际价格 × 100164uint32收盘价实际价格 × 100204float32成交额单位是元浮点数存储244uint32成交量单位是股不是手284uint32保留字段通常为 0四个价格为什么要乘 100因为这样可以把两位小数的价格变成整数存储既节省空间又避免浮点误差。比如 7.25 元存成 7257.30 元存成 730解析的时候再统一除以 100 还原。这里特别要注意两点第一成交量单位是股如果你平时看盘习惯用手那么要自己除以 100第二不同通达信内核版本在字段 6 和字段 7 上可能会有排列差异有的版本是成交量在前、成交额在后解析完后一定要用真实数据比对一下量级我后面会专门说这个问题。2.2 用 Python 拆开第一个字节在写完整解析器之前先动手验证一下这个结构。用 Python 读取 sh600000.day 的前 32 个字节with open(rC:\zd_swhy_gm\vipdoc\sh\lday\sh600000.day, rb) as f: raw f.read(32) print(raw.hex( ))输出是一串十六进制字节看起来毫无规律。接着用 struct 模块按我们推断的格式解包import struct fields struct.unpack(IIIIIfII, raw) print(fields)输出大致长这样示意数据不同下载时点结果不同(20240628, 725, 738, 721, 730, 612300000.0, 89345600, 0)对照前面的字段表20240628 是日期725 是 7.25 元的开盘价738 是最高价 7.38 元721 是最低价 7.21 元730 是收盘价 7.30 元成交额约 6.12 亿元成交量 8934.56 万股保留字段为 0。逻辑完全对上了。2.3 struct 模块与字节序的匹配关系上面代码里最容易被忽略的是格式字符串开头的。它的意思是小端字节序 标准大小无对齐。通达信在 Windows 上写出的数据是小端序little-endian也就是低字节在前。如果你把换成大端序解出来的日期会变成一个巨大数字价格也完全不成样子。另一个隐藏问题是 struct 默认的对齐方式。由于这个文件里 8 个字段全部是 4 字节刚好没有对齐空洞所以用不带前缀的默认格式也碰巧能解出正确结果。但这是一个很不好的习惯一旦遇到结构里有 2 字节字段默认对齐就会插入 padding导致字节错位。我的建议是直接写死IIIIIfII把字节序和对齐方式都显式固定下来一劳永逸。实际项目里不要每次都调 struct.unpack可以用 struct.Struct 对象缓存格式性能更好DAY_RECORD struct.Struct(IIIIIfII) DAY_RECORD_SIZE DAY_RECORD.size # 32 fields DAY_RECORD.unpack(raw)3. 从单文件解析到全目录批量入库3.1 单文件解析函数先写一个函数专门解析单个 .day 文件。核心逻辑就是不断按 32 字节切块直到文件末尾。虽然理论上一个文件是 32 的整数倍但为了稳妥还是要处理最后不足 32 字节的残留——那通常是文件没写完整直接丢弃就好。from pathlib import Path DAY_RECORD struct.Struct(IIIIIfII) DAY_RECORD_SIZE DAY_RECORD.size # 32 def parse_day_file(file_path): records [] with open(file_path, rb) as f: while True: chunk f.read(DAY_RECORD_SIZE) if len(chunk) DAY_RECORD_SIZE: break date_int, open_raw, high_raw, low_raw, close_raw, amount, volume, reserved DAY_RECORD.unpack(chunk) records.append(( file_path.stem, # 比如 sh600000 str(date_int), # 比如 20240628 open_raw / 100.0, high_raw / 100.0, low_raw / 100.0, close_raw / 100.0, round(amount, 2), volume, )) return records为什么 records 里每行用元组而不是字典因为下一步要直接批量写入 sqlite3executemany 接收的就是元组列表省一次转换。如果你需要先做复杂清洗可以先转成字典列表再统一处理灵活取舍。3.2 SQLite 表结构与复合主键数据解析出来下一步就是设计表结构。日线数据非常适合关系型存储因为它的维度很规整一只股票 一个交易日 一根K线。我用下面这张表来承载CREATE TABLE IF NOT EXISTS daily_bar ( symbol 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 (symbol, trade_date) );这里 trade_date 用 TEXT 类型存储 YYYYMMDD 格式的字符串而不是 INTEGER。原因有两点一是字符串按字典序排序时和日期顺序一致WHERE 条件写起来直观二是后续如果要输出成 datetime 类型Python 侧解析字符串非常方便。复合主键 (symbol, trade_date) 也非常关键。同一只股票、同一个交易日只可能有一条日线记录。把这两个字段作为主键天然防止了重复写入。后面做增量更新时配合INSERT OR REPLACE或者INSERT ON CONFLICT可以做到幂等写入跑多少遍都不会产生脏数据。3.3 批量处理 vipdoc 目录下的全部 .day 文件只解析一个 sh600000 显然不够过瘾。日常做研究需要的往往是一整个市场的日线数据好在 vipdoc 目录里几百个 .day 文件结构完全一致只要循环解析即可。我习惯把整个流程封装成一个类一是方便管理连接资源二是后续扩展分钟线、扩展字段时不用改动调用方代码import sqlite3 import time from pathlib import Path DATA_DIR Path(rC:\zd_swhy_gm\vipdoc\sh\lday) DB_PATH Path(rD:\quant_data\tdx_swhy.db) class DayToSqlite: def __init__(self, db_path): self.conn sqlite3.connect(str(db_path)) self._init_table() def _init_table(self): self.conn.execute( CREATE TABLE IF NOT EXISTS daily_bar ( symbol 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 (symbol, trade_date) ) ) self.conn.commit() def import_file(self, file_path): records parse_day_file(file_path) if not records: print(f{file_path.name}: 没有解析到记录) return 0 self.conn.executemany( INSERT OR REPLACE INTO daily_bar (symbol, trade_date, open, high, low, close, amount, volume) VALUES (?, ?, ?, ?, ?, ?, ?, ?), records, ) self.conn.commit() return len(records) def close(self): self.conn.close() def collect_day_files(data_dir): files list(data_dir.glob(*.day)) if not files: files list(data_dir.glob(*.DAY)) return files主流程很简单if __name__ __main__: importer DayToSqlite(DB_PATH) day_files collect_day_files(DATA_DIR) print(f找到 {len(day_files)} 个日线文件) start time.time() for day_file in day_files: count importer.import_file(day_file) print(f{day_file.name}: {count} 条) importer.close() print(f全部完成耗时 {time.time() - start:.2f} 秒)这个脚本跑完你就在本地拥有了一张覆盖上海市场全部证券的日线数据表随时可以 SQL 查询。几百个文件解析入库总耗时通常只有几秒钟因为 .day 文件本身就是紧凑的二进制格式IO 和解析效率都非常高。3.4 插入性能与事务提交sqlite3 写入性能的核心是事务。如果你逐条 INSERT 并且每条都 commit几百个文件下来速度会慢得想砸电脑。上面的代码用的是 executemany 每文件 commit 一次这个粒度在日线场景下很合适。如果你要把整个市场几百万条记录一次灌入还可以把 commit 放到全部文件处理完之后但那样做风险是中途断了就要全部重来。个人经验是每文件提交一次性价比最高既能保证单文件粒度的可恢复性又能把事务开销压到可以忽略的程度。4. 实测 sh600000 并核查数据质量4.1 浦发银行日线数据抽样脚本跑完从 sqlite3 里直接把 sh600000 最近几天的数据捞出来看看import sqlite3 conn sqlite3.connect(rD:\quant_data\tdx_swhy.db) cur conn.cursor() cur.execute( SELECT trade_date, open, high, low, close, volume, amount FROM daily_bar WHERE symbol sh600000 ORDER BY trade_date DESC LIMIT 5 ) for row in cur.fetchall(): print(row)输出效果类似下面这样示意数据具体以本地终端实际下载情况为准(20240628, 7.25, 7.38, 7.21, 7.30, 89345600, 612300000.0) (20240627, 7.18, 7.29, 7.15, 7.24, 76812300, 552987000.0) (20240626, 7.05, 7.22, 7.03, 7.20, 102345600, 724560000.0) (20240625, 7.10, 7.17, 7.00, 7.08, 89987600, 631234000.0) (20240624, 7.30, 7.32, 7.16, 7.19, 95678100, 682345000.0)至少从格式上看字段对齐了价格量级也符合常识。但这只是看起来对要真正放心还需要做几道校验。4.2 数量与范围校验我常用的校验手段是几条 SQL 语句。先看记录总数和日期范围SELECT COUNT(*), MIN(trade_date), MAX(trade_date) FROM daily_bar WHERE symbol sh600000;如果 COUNT 的结果和通达信软件里显示的K线根数对得上说明解析没有丢数据、也没有凭空多出数据。再看价格区间SELECT MIN(low), MAX(high) FROM daily_bar WHERE symbol sh600000;如果你对浦发银行的历史价格有概念扫一眼区间就知道有没有解析错误。比如几千倍的异常价格往往就是字节序反了或者字段错位了。还可以检查量价的合理性SELECT COUNT(*) FROM daily_bar WHERE symbol sh600000 AND close high OR close low;这一条的理想结果应该是 0。如果出现记录说明数据本身或解析逻辑有严重问题。4.3 和软件界面做交叉验证机器校验只能证明内部一致最终还是要跟可信来源对一下。最简单的办法打开申万宏源金融终端找到浦发银行的日线图随便挑一个近期交易日对比软件界面显示的收盘价和数据库里的值是否一致。这里有个关键前提——要看软件里的不复权价格。通达信本地 .day 文件存的是不复权原始价格但终端界面默认打开时往往显示的是前复权两者在发生过除权除息后会出现明显偏差。如果你拿复权后的界面数字跟数据库比对会认为是解析错了其实是参照系错了。这也自然引出本文最值得说道的一个坑复权。5. 解析过程中的常见坑与对应解法5.1 字节序与对齐问题解析结果全是天文数字我见过很多人在第一步就卡住用 struct.unpack 解出来的日期是 537202688 这种毫无意义的大数价格全部变成几百万。根因基本都是字节序。Windows 上写入的文件是小端序你的格式字符串没加或者写成了就会把字节从后往前读所有数值全部错乱。解法很简单统一用前缀。判断自己有没有踩坑也很简单解析出来的日期应该是一个类似 20240628 的八位数字开盘价应该是几百而不是几十万。还有一个小概率情况字段 6 和字段 7 顺序反了。前面说过少数通达信内核版本的成交额和成交量排列不同。判断方法是看量级如果解析后 volume 变成了几亿甚至几十亿amount 变成了几十万而实际这是一只低价大盘股那大概率是这两个字段搞反了。把格式从IIIIIfII换成IIIIIIfI再试一次。5.2 复权问题本地日线是不复权的原始数据这是整个方案里最容易让人栽跟头的业务问题。通达信本地 vipdoc 里的 .day 文件默认保存的是不复权价格。也就是说如果一只股票在某天进行了 10 派 5 的分红次日价格在图表上会突然向下跳空一大截看起来像暴跌但实际是除息导致的。做回测、算收益率时如果不处理复权结果会被这些假跳空严重误导。业内一般有两种处理思路一是用前复权因子表。把每只股票的除权除息公告整理成事件表在导入 sqlite3 时记录事件日期和除权因子计算收益率时动态复权。这个方法很精细但需要额外的数据维护成本。二是直接用通达信自行下载复权数据。如果你一定要本地复权价可以在软件里用数据下载工具或盘后数据下载功能选择前复权日线数据这时软件生成的本地文件可能已经是复权后的价格。但要注意这样就把原始价格覆盖了不利于以后转换其他算法。我的建议是日线基础表保留不复权原始数据另建一张 factor 表专门存复权因子分析时再 join 计算。这样原始数据不被污染复权策略可以随时调整。5.3 文件后缀大小写、停牌缺口、指数混入如果把同样的脚本从 Windows 挪到 Linux 服务器上跑第一个坑就是文件后缀大小写。Windows 文件系统不区分大小写glob(*.day) 能把 .DAY 的文件也找出来Linux 下则严格区分会漏掉大写后缀的文件。所以我在批量收集文件时同时匹配了两种大小写。停牌日没有记录这件事也值得新手上路时注意。有些朋友查数据发现某段时间缺了几天第一反应以为是解析出错。其实 .day 文件的生成逻辑是有交易才写记录停牌日、节假日天然没有记录这是正常现象不要自己脑补填充。如果你需要连续交易日序列做对齐操作应该用自定义交易日历去 left join而不是补数据。另外lday 目录里不只有股票还有指数。比如上海市场目录下的 sh000001.day 就是上证指数。如果你建表时把所有文件都导入同一张表后续按 symbol 过滤时要把指数代码排除掉或者在建表时加一个 asset_type 字段区分股票、指数、基金。这个字段其实很有用我是在解析完整个目录后补上的建议你一开始就加上。5.4 字段类型与未来扩展sqlite3 对 REAL 和 INTEGER 的类型约束相对宽松但你在 Python 里拿到数据后要注意区分。成交量我故意存成 INTEGER因为它是整数成交额我存成 REALfloat32 解析出来本身就有精度限制好在展示级别的分析完全够用。如果你要做非常精确的财务计算最好在清洗阶段就用 Decimal 重新处理。这个数据库也不会只停留在日线。vipdoc 下的 minline 目录还有分钟线fzline 还有分笔成交它们的格式和 .day 不同但思路一致先搞清二进制布局再写解析器最后入表。等你把日线这条链路跑通后续扩展到分钟线只是换一个 struct 格式和表结构的问题。最后分享几个实际操作习惯解析完数据不是终点让数据保持可用才是。我自己的做法是给 daily_bar 表加上更新时间字段每次跑完脚本后记录当前时间。这样后续如果同一个文件被重新解析我能知道哪些数据是新的哪些可能过期了。增量更新方面直接复用之前的 import_file 逻辑即可。因为主键存在INSERT OR REPLACE遇到相同 (symbol, trade_date) 会自动覆盖旧记录所以每次打开券商终端、等它自动补齐最新K线后再跑一遍脚本数据库就完成了增量同步。这个操作完全可以做成定时任务但建议至少保留一次手工执行验证确认终端确实下载了最新数据。最后提醒一句解析本地数据之前先确保终端已经把需要的行情下载完整。打开申万宏源金融终端把你想研究的股票K线翻到最新让软件把缺失的历史数据拉到本地再去解析。数据源完整解析器才有意义。这套本地数据转 sqlite3的思路我用在多个券商终端上都很稳定核心就是搞清楚 .day 的二进制格式剩下的就是工程化的问题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑