简介:这份资源面向金融数据分析初学者与量化爱好者,聚焦同花顺日线二进制文件(.day)的解析问题。包内共2个文件,包含一个以股票代码600000命名的日线数据样本,以及一份Python解析脚本,压缩包体积约4KB,属于轻量级实战示例。脚本以约20行代码演示了二进制读取、字段偏移解析、数值转换与结构化存储的完整思路,可帮助读者理解.day文件的内部编码方式,并快速迁移到其他股票历史数据的批量处理场景。目前已有2258人学习下载,适合希望掌握Python二进制文件操作、打通行情数据获取环节的开发者参考借鉴。
1. 同花顺日线文件解析:为什么 20 行 Python 就能撬开一个二进制黑匣子
很多人第一次拿到同花顺的日线文件时,会下意识地把它当成 CSV 或者某种文本格式,用记事本打开,结果看到的是一堆乱码。于是第一反应是「这文件加密了」或者「这格式没法读」。但真相恰恰相反:它没有加密,也没有压缩,只是把结构体按二进制顺序直接写进了磁盘。你之所以看不懂,是因为你在用文本的眼睛看二进制的世界。
这件事值得搞懂的地方在于:一旦你理解了它的布局规则,解析代码可以短到 20 行以内,而且不需要任何第三方库。更关键的是,这种「定长记录 + 顺序排列」的设计思路,在金融数据存储里非常典型。搞懂一个,后面遇到类似格式的文件,你都能用同一套思维去拆。这篇文章就从这个反直觉的结论出发,把日线文件的二进制布局、Python 的 struct 解析机制、以及为什么 20 行就够用,一层层拆开讲清楚。
2. 二进制布局:日线文件到底把什么写进了磁盘
2.1 定长记录:每条日线占多少字节
同花顺日线文件的核心设计是「定长记录」。也就是说,每一天的行情数据占用固定数量的字节,所有交易日按时间顺序依次排列。你只要知道单条记录的长度,就能用「文件总大小 ÷ 单条长度」算出总共有多少个交易日。
常见的实现思路是:每条日线记录包含日期、开盘价、最高价、最低价、收盘价、成交量、成交额这几个字段。日期通常用 4 字节整数表示,格式是 YYYYMMDD,比如 20240115。价格字段用 4 字节浮点数或整数分单位存储,成交量用 4 字节整数,成交额用 4 字节浮点数。加起来一条记录大约 28 到 32 字节。
这里有个容易翻车的地方:不同版本的同花顺客户端,字段顺序和字节长度可能不一样。有的版本价格用 float32,有的用 int32 表示「分」。所以你不能死记一个长度,而是要先验证。验证方法很简单:用文件总大小除以你猜测的单条长度,如果结果是整数,而且这个整数和该股票上市以来的交易日数量大致吻合,那你的猜测大概率是对的。
import os def guess_record_size(filepath): size = os.path.getsize(filepath) # 常见候选长度:28、32、36 for rec in [28, 32, 36]: if size % rec == 0: days = size // rec print(f"候选记录长度 {rec} 字节,共 {days} 条记录") return size guess_record_size("sz000001.day")这段代码的逻辑是:遍历几个常见的记录长度,看文件大小能否被整除。如果能整除,说明这个长度很可能是对的。参数 filepath 是日线文件的路径,返回值是文件总字节数。注意,整除只是必要条件,不是充分条件,你还需要结合交易日数量做二次确认。
2.2 字节序与对齐:为什么读出来的数字是乱的
二进制解析里最隐蔽的坑是字节序。x86 架构的机器用小端序,也就是低位字节在前。如果你用大端序去读,读出来的数字会完全不对,比如 20240115 可能变成 15014120 这种离谱的值。
Python 的 struct 模块默认使用本机字节序,但为了跨平台一致性,建议显式指定。格式字符串里,<表示小端序,>表示大端序。同花顺的文件通常是小端序,所以格式字符串一般以<开头。
另一个坑是内存对齐。C 语言的结构体在内存里可能会有填充字节,但文件存储时通常是紧凑排列的,没有填充。所以你不能直接拿 C 的 sizeof 结果当记录长度,而是要把每个字段的实际字节数加起来。
import struct def parse_one_record(data, offset): # < 表示小端序 # I 表示 4 字节无符号整数(日期) # f 表示 4 字节浮点数(开盘、最高、最低、收盘) # I 表示 4 字节无符号整数(成交量) # f 表示 4 字节浮点数(成交额) fmt = "<IffffIf" fields = struct.unpack_from(fmt, data, offset) date, open_, high, low, close, volume, amount = fields return { "date": date, "open": open_, "high": high, "low": low, "close": close, "volume": volume, "amount": amount }这段代码用 struct.unpack_from 从指定偏移量开始解析一条记录。格式字符串<IffffIf的含义是:小端序,一个 4 字节整数,四个 4 字节浮点数,一个 4 字节整数,一个 4 字节浮点数。总共 28 字节。解析出来的字段按顺序赋值给变量,最后组装成字典返回。参数 data 是文件的字节内容,offset 是当前记录在文件中的起始位置。
2.3 日期与价格的还原:从整数到可读格式
日期字段读出来是 20240115 这样的整数,你需要把它转换成 Python 的 date 对象或者字符串。转换方法很简单:先转成字符串,再按位置切片。
价格字段如果是浮点数,直接使用即可。但如果是整数分单位,你需要除以 100 或 1000。这里有个血泪经验:不同市场、不同品种的价格精度可能不一样。A 股通常精确到分,也就是除以 100。但有些数据源用厘,那就得除以 1000。你最好拿一只你熟悉的股票,手动核对几天的收盘价,确认精度。
from datetime import datetime def format_date(date_int): s = str(date_int) return datetime.strptime(s, "%Y%m%d").date() def format_price(price_raw, divisor=100): return price_raw / divisorformat_date 把整数日期转成 date 对象,format_price 把原始价格按除数还原。参数 divisor 默认 100,如果你的数据源精度不同,改这个参数即可。核对方法:找一天你记得的收盘价,对比解析结果,如果差 100 倍或 1000 倍,就调整 divisor。
3. 20 行代码的底层逻辑:struct 模块到底做了什么
3.1 struct.unpack 的机制:格式字符串如何映射字节
struct 模块的核心是格式字符串。每个字符代表一种数据类型,比如 I 代表 4 字节无符号整数,f 代表 4 字节浮点数,d 代表 8 字节双精度浮点数。当你调用 struct.unpack 时,Python 会按照格式字符串的顺序,从字节流里依次取出对应长度的字节,然后按照指定的类型解释成 Python 对象。
这个过程是零拷贝的——它直接在原字节串上操作,不会创建中间对象。所以解析速度非常快,几万条记录也是毫秒级。这也是为什么 20 行代码就够用的根本原因:Python 把复杂的字节操作封装成了一个格式字符串,你只需要描述布局,不需要手动移位和拼接。
常见的实现思路是:先用 open 以二进制模式读取整个文件到内存,然后用 struct.unpack_from 循环解析。unpack_from 比 unpack 更适合这种场景,因为它可以从指定偏移量开始解析,不需要每次切片。
import struct def parse_day_file(filepath): with open(filepath, "rb") as f: data = f.read() fmt = "<IffffIf" rec_size = struct.calcsize(fmt) records = [] for offset in range(0, len(data), rec_size): fields = struct.unpack_from(fmt, data, offset) records.append(fields) return records这段代码就是完整的解析逻辑。先读整个文件,然后用 struct.calcsize 算出单条记录长度,接着用 range 按步长遍历偏移量,每次解析一条记录。参数 filepath 是文件路径,返回值是记录列表。注意,这里没有做日期和价格的格式化,只是原始解析。你可以在此基础上加格式化逻辑。
3.2 为什么不用 pandas:内存与速度的权衡
很多人会问:为什么不直接用 pandas.read_csv 或者 numpy.fromfile?答案是:pandas 读不了这种自定义二进制格式,numpy.fromfile 可以读,但你需要先定义 dtype,而 dtype 的定义比 struct 格式字符串更繁琐。对于这种简单的定长记录,struct 是最直接的工具。
另一个考虑是内存。如果你只需要解析少量文件,直接读进内存没问题。但如果你要批量处理几千个文件,每个文件几万条记录,全部读进内存可能会爆。这时候你可以用分块读取,每次只读一部分,解析完就释放。不过对于日线文件来说,单文件通常只有几百 KB 到几 MB,全读进内存完全没问题。
import numpy as np def parse_with_numpy(filepath): dtype = np.dtype([ ("date", "<u4"), ("open", "<f4"), ("high", "<f4"), ("low", "<f4"), ("close", "<f4"), ("volume", "<u4"), ("amount", "<f4") ]) return np.fromfile(filepath, dtype=dtype)这段代码用 numpy 的 dtype 定义记录结构,然后 fromfile 直接读取。效果和 struct 一样,但返回的是结构化数组,后续做向量化计算更方便。参数 filepath 是文件路径,返回值是 numpy 数组。如果你后续要做均线、涨跌幅等计算,numpy 版本会更顺手。
3.3 批量解析与性能:几万条记录为什么是毫秒级
struct.unpack_from 的性能非常高,因为它底层是 C 实现的。对于几万条记录,解析时间通常在几毫秒到几十毫秒之间。如果你用纯 Python 手动移位和拼接,速度会慢几十倍。
批量解析的关键是避免重复打开文件。你可以先把所有文件路径收集起来,然后循环解析。如果文件数量很大,可以考虑用多进程并行。但要注意,磁盘 IO 可能是瓶颈,并行不一定能提升多少。
import os import struct from concurrent.futures import ProcessPoolExecutor def parse_one(filepath): with open(filepath, "rb") as f: data = f.read() fmt = "<IffffIf" rec_size = struct.calcsize(fmt) return [struct.unpack_from(fmt, data, off) for off in range(0, len(data), rec_size)] def batch_parse(dirpath): files = [os.path.join(dirpath, f) for f in os.listdir(dirpath) if f.endswith(".day")] with ProcessPoolExecutor() as executor: results = list(executor.map(parse_one, files)) return results这段代码用 ProcessPoolExecutor 并行解析多个文件。parse_one 负责单个文件的解析,batch_parse 负责收集文件并分发任务。参数 dirpath 是存放日线文件的目录,返回值是解析结果列表。注意,并行解析时每个进程都会读文件,如果文件在机械硬盘上,随机读可能反而变慢。建议先测试串行版本,确认瓶颈后再决定是否并行。
4. 避坑与排查:解析日线文件时最容易翻车的几个点
4.1 现象:解析出来的价格全是 0 或者极小值
原因:价格字段的精度搞错了。有些数据源用整数存储,单位是分,你直接当浮点数读,就会得到一个很小的值。或者字段顺序搞错了,把成交量当成了价格。
解决:先打印原始字节的十六进制,手动核对。比如收盘价 10.50 元,如果存储为分,应该是 1050,十六进制是 0x0000041A。你在字节流里找到这个值,就能确认字段位置和精度。
4.2 现象:日期读出来是 15014120 这种乱码
原因:字节序搞反了。小端序读成了大端序,或者反过来。
解决:把格式字符串里的<改成>,或者反过来,再试一次。如果日期变得合理了,说明字节序对了。
4.3 现象:文件大小不能被常见记录长度整除
原因:文件可能包含头部信息,或者记录长度不是常见的 28/32/36。有些版本会在文件开头加一个固定长度的头部,存储股票代码、市场标识等元信息。
解决:用十六进制编辑器打开文件,观察前几十个字节。如果开头有一段看起来不像行情数据的区域,尝试跳过它。你可以用二分法:假设头部长度是 64 字节,从偏移 64 开始解析,看日期是否合理。
4.4 现象:解析到一半报 struct.error
原因:文件末尾有不完整的记录,或者文件被截断了。
解决:在循环里加异常捕获,遇到错误就跳过或停止。同时检查文件大小是否能被记录长度整除,如果不能,说明文件可能损坏。
def safe_parse(filepath): with open(filepath, "rb") as f: data = f.read() fmt = "<IffffIf" rec_size = struct.calcsize(fmt) records = [] for offset in range(0, len(data) - rec_size + 1, rec_size): try: records.append(struct.unpack_from(fmt, data, offset)) except struct.error as e: print(f"偏移 {offset} 解析失败: {e}") break return records这段代码在循环条件里减去了 rec_size,确保不会读到不完整的记录。同时用 try-except 捕获异常,打印失败偏移量。参数 filepath 是文件路径,返回值是成功解析的记录列表。
4.5 现象:批量解析时内存暴涨
原因:一次性把所有文件的内容都读进了内存。
解决:改成生成器模式,每次只处理一个文件,处理完就释放。或者用分块读取,每次读固定大小的块。
def iter_parse(filepath, chunk_size=4096): fmt = "<IffffIf" rec_size = struct.calcsize(fmt) with open(filepath, "rb") as f: buffer = b"" while True: chunk = f.read(chunk_size) if not chunk: break buffer += chunk while len(buffer) >= rec_size: yield struct.unpack_from(fmt, buffer, 0) buffer = buffer[rec_size:]这段代码用生成器逐条产出记录,每次只保留一个缓冲区。参数 chunk_size 是每次读取的字节数,默认 4096。注意,缓冲区拼接时可能会有性能损耗,如果追求极致速度,可以用 memoryview 优化。
5. 设计取舍:为什么是定长记录,而不是别的格式
定长记录的设计取舍,本质上是在「解析速度」和「存储效率」之间做选择。定长记录的优点是解析极快,因为每条记录的偏移量可以直接算出来,不需要扫描分隔符。缺点是存储效率不高,比如日期用 4 字节整数存储,其实用 3 字节就够了,但为了对齐和解析方便,多花 1 字节是值得的。
另一个取舍是「自描述性」。定长记录没有头部说明字段布局,你必须提前知道格式才能解析。这降低了文件的通用性,但提高了读写速度。在金融数据场景下,数据量大、读写频繁,速度优先于通用性,所以这种设计是合理的。
如果你要设计类似的存储格式,我一般会这么理解:先确定字段和精度,然后算总长度,尽量让长度是 4 的倍数,方便内存对齐。如果未来可能加字段,可以在末尾预留几个字节的保留位。但不要过度设计,因为一旦格式定了,改起来很麻烦。
验证你的解析是否正确,最可靠的方法是对比。找一只你熟悉的股票,拿几天的收盘价和成交量,手动核对。如果连续多天都对得上,说明你的格式字符串和精度设置是正确的。从那以后,我每次解析新的二进制格式,都会先拿几条记录做人工核对,确认无误后再批量跑。希望帮到你。
本文还有配套的精品资源,点击获取