1. 从通达信.day文件到CSV:一个数据工程师的日常
今天想聊聊一个在金融数据领域,尤其是个人量化研究初期,几乎人人都会遇到的一个“脏活累活”:把通达信软件的.day日线数据文件,转换成我们更熟悉的CSV格式。这听起来像是个简单的格式转换,但如果你真的动手做过,就知道里面藏着不少门道。为什么不用现成的数据源?为什么非得是通达信?这背后其实是一个关于数据自主性、成本控制以及历史数据完整性的故事。很多朋友刚开始做量化回测,第一道坎往往不是策略本身,而是找不到一份干净、连续、覆盖足够长时间的历史行情数据。网上的免费API要么有频率限制,要么数据质量参差不齐,而专业的金融数据服务对于个人研究者来说又价格不菲。通达信的.day文件,作为一款在国内市场占有率极高的终端软件本地存储的格式,反而成了一个稳定、免费且数据质量相对可靠的“宝藏”。把它用好,相当于为自己搭建了一个本地的、可控的基础数据仓库。
这个过程的核心,就是理解.day这个二进制文件的“黑盒”结构,并把它“翻译”成人类可读、程序可处理的表格形式。我最初接触这个需求,是为了回测一个需要用到十几年A股全市场日线数据的策略。从网上零散地下载CSV,总会遇到格式不统一、字段缺失、复权方式混乱的问题,折腾得心力交瘁。最后下定决心,直接从最源头的.day文件开始处理,虽然前期解析需要花些功夫,但一旦流程跑通,后续的数据维护和更新就变得异常清晰和高效。这期内容,我就把自己趟过的路、踩过的坑,以及最终稳定运行的代码逻辑,毫无保留地分享出来。你会发现,这个转换过程不仅是一个技术实现,更是一种数据治理思维的体现。
2. 理解源头:通达信.day文件的二进制结构解析
在动手写代码之前,我们必须先搞清楚我们要处理的“原材料”到底是什么。通达信的.day文件是一种紧凑的二进制格式,设计初衷是为了软件自身快速读取和存储,而不是为了开放交换。每个股票对应一个.day文件,例如sh600000.day就代表了浦发银行的日线数据。它的结构是固定的,每32个字节(bytes)记录一天的行情数据。这32个字节,就像一条记录了当天所有关键信息的“数据记录”,按照特定的顺序排列。
我们可以把这32个字节拆解成若干个字段,每个字段都有固定的数据类型和长度。以下是一个通用的结构定义(具体字节顺序可能因通达信版本略有差异,但主流版本如下):
| 字节偏移 (起始为0) | 字段长度 (字节) | 数据类型 | 字段含义 | 解析说明 |
|---|---|---|---|---|
| 0 - 3 | 4 | unsigned int | 日期 (Date) | 存储格式为YYYYMMDD的整数,如20231027。 |
| 4 - 7 | 4 | unsigned int | 开盘价 (Open) | 实际价格乘以1000(或100,取决于版本),以整数存储。常见是乘以1000,即精确到0.001元。 |
| 8 - 11 | 4 | unsigned int | 最高价 (High) | 同上,实际价格乘以1000。 |
| 12 - 15 | 4 | unsigned int | 最低价 (Low) | 同上,实际价格乘以1000。 |
| 16 - 19 | 4 | unsigned int | 收盘价 (Close) | 同上,实际价格乘以1000。 |
| 20 - 23 | 4 | float | 成交金额 (Amount) | 以“万元”为单位的浮点数。这是最容易混淆的地方,单位是万,不是元。 |
| 24 - 27 | 4 | unsigned int | 成交量 (Volume) | 以“手”为单位的整数(1手=100股)。 |
| 28 - 31 | 4 | unsigned int | 保留字段 | 通常为0,不同版本可能用于存储换手率等其他信息,但主流.day文件此字段忽略。 |
注意:价格相关的“缩放因子”是第一个关键坑点。通达信早期为节省存储空间和避免浮点数精度问题,将价格以整数形式存储。你需要确认你的数据源使用的缩放因子是100还是1000。一个简单的判断方法是:找一只你知道价格的股票,用代码读出一个收盘价的整数值,除以100和1000,看哪个结果更接近真实交易价格。目前市面上大部分导出的数据,缩放因子是1000。
第二个关键点是字节序。通达信软件运行在Windows系统上,而Windows通常采用小端序。这意味着在读取这4字节的整数或浮点数时,我们需要按照小端序来解析。Python的struct模块可以轻松处理这个问题。例如,读取日期字段,我们需要用‘<I’这个格式符(<代表小端,I代表4字节无符号整数)。
理解了这个结构,转换的逻辑就清晰了:打开一个.day文件,以二进制模式读取,每次读取32个字节作为一个数据块,然后用struct.unpack按照上述格式解析这个数据块,得到原始的数值,最后进行单位换算(价格除以缩放因子,成交金额乘以10000转换为“元”),并格式化为CSV的一行。这个过程本质上是一个“数据反序列化”。
3. 实战代码拆解:从文件读取到CSV写入的完整流程
理论清晰后,我们来看具体的Python实现。我会用一个函数化的、结构清晰的代码示例,并解释每一部分的意图和可能遇到的细节问题。
首先,我们需要准备环境。除了Python标准库,核心只用到了struct模块,无需额外安装。
import struct import os import pandas as pd from pathlib import Path from typing import List, Optional # 定义常量,根据你的数据源调整 RECORD_SIZE = 32 # 每条记录固定32字节 PRICE_SCALE = 1000.0 # 价格缩放因子,可能是100或1000 AMOUNT_SCALE = 10000.0 # 成交金额单位是“万元”,乘以10000得到“元” def parse_tdx_day_file(file_path: str) -> List[dict]: """ 解析单个通达信.day文件,返回字典列表。 Args: file_path: .day文件的完整路径。 Returns: 按日期倒序排列的日线数据列表,每个元素是一个字典。 """ data_list = [] try: with open(file_path, 'rb') as f: while True: # 每次读取32字节 chunk = f.read(RECORD_SIZE) if not chunk or len(chunk) < RECORD_SIZE: break # 文件读取完毕或数据不完整 # 使用小端序解析32字节数据块 # 格式字符串: < I I I I I f I I (具体含义见下表) # 注意:最后一个I是保留字段,我们通常忽略 date_raw, open_raw, high_raw, low_raw, close_raw, amount_raw, volume_raw, _ = \ struct.unpack('<IIIIIfII', chunk) # 1. 处理日期:整数转字符串 ‘YYYY-MM-DD’ date_str = str(date_raw) formatted_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" # 2. 处理价格:除以缩放因子,保留3位小数 open_price = open_raw / PRICE_SCALE high_price = high_raw / PRICE_SCALE low_price = low_raw / PRICE_SCALE close_price = close_raw / PRICE_SCALE # 3. 处理成交金额:万元转元,并保留2位小数(通常足够) # 注意:amount_raw本身就是浮点数(万元),例如12.5代表12.5万元 amount_yuan = amount_raw * AMOUNT_SCALE # 4. 成交量单位已是“手”,直接使用 volume = volume_raw data_list.append({ 'date': formatted_date, 'open': round(open_price, 3), 'high': round(high_price, 3), 'low': round(low_price, 3), 'close': round(close_price, 3), 'amount': round(amount_yuan, 2), # 成交额,单位元 'volume': volume, # 成交量,单位手 }) except FileNotFoundError: print(f"文件未找到: {file_path}") return [] except struct.error as e: print(f"解析文件 {file_path} 时发生结构错误,文件可能已损坏或格式不符: {e}") return [] # 通达信.day文件默认按日期倒序存储(最新数据在前) # 我们通常希望CSV按日期正序排列,所以这里反转列表 return data_list[::-1]这段代码是核心解析器。struct.unpack(‘<IIIIIfII’, chunk)这一行是关键,它严格按照我们前面分析的8个字段顺序和类型进行解析。这里有一个非常重要的细节:通达信的.day文件内部是按日期倒序排列的,即文件开头是最近一天的行情,文件末尾是最早的数据。所以我们在函数最后使用了data_list[::-1]将列表反转,这样输出到CSV时,数据就是按日期从远到近的正序排列,符合大多数分析工具的习惯。
接下来,我们需要一个函数来处理批量转换,并生成CSV。
def convert_day_dir_to_csv(day_dir: str, output_dir: str, code_filter: Optional[str] = None): """ 将一个目录下的所有.day文件批量转换为CSV。 Args: day_dir: 存放.day文件的目录路径。 output_dir: 输出CSV文件的目录路径。 code_filter: 可选,股票代码过滤(如'sh600000'),只转换匹配的文件。 """ day_path = Path(day_dir) output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) # 创建输出目录 # 遍历目录下所有.day文件 for day_file in day_path.glob('*.day'): stock_code = day_file.stem # 获取文件名,不含后缀,如'sh600000' # 如果指定了过滤条件,则进行匹配 if code_filter and code_filter not in stock_code: continue print(f"正在处理: {stock_code}") data = parse_tdx_day_file(str(day_file)) if not data: print(f" -> 警告: {stock_code} 未解析出数据,可能文件为空或损坏。") continue # 使用pandas DataFrame进行整理和保存 df = pd.DataFrame(data) # 设置日期为索引(可选,但非常方便) df['date'] = pd.to_datetime(df['date']) df.set_index('date', inplace=True) # 保存为CSV csv_file = output_path / f"{stock_code}.csv" df.to_csv(csv_file, encoding='utf-8-sig') # 使用utf-8-sig避免中文Excel乱码 print(f" -> 已保存: {csv_file}, 共 {len(df)} 条记录。")这个批量处理函数展示了如何将单个解析函数应用到整个目录。这里有几个实用的设计点:
- 使用
pathlib.Path:这是处理文件路径的现代、安全的方式,比直接拼接字符串更优雅。 - 创建输出目录:
output_path.mkdir(parents=True, exist_ok=True)这一行确保了输出目录存在,避免运行时错误。 - 编码问题:保存CSV时指定
encoding=‘utf-8-sig’,这个编码会在文件开头添加一个BOM(字节顺序标记),使得用微软Excel打开时能正确识别UTF-8编码,避免中文乱码。这是处理金融数据时的一个小技巧。 - 使用Pandas:虽然我们完全可以用Python标准库写CSV,但Pandas的
DataFrame在数据清洗、查看和后续处理上提供了巨大的便利。将日期列转换为datetime类型并设为索引,后续进行时间序列分析会非常方便。
你可以这样调用这个函数:
if __name__ == '__main__': # 假设你的.day文件放在 'D:/tdx_data/day' 下 source_dir = 'D:/tdx_data/day' # 希望输出的CSV放在 'D:/tdx_data/csv' 下 target_dir = 'D:/tdx_data/csv' convert_day_dir_to_csv(source_dir, target_dir) # 如果只想转换某一只股票,可以加上过滤 # convert_day_dir_to_csv(source_dir, target_dir, code_filter='sh600000')4. 关键细节、常见陷阱与数据质量校验
代码跑起来不难,但要让转换后的数据真正可靠,用于严肃的回测,我们还得关注以下几个容易出错的细节和必须进行的校验步骤。
4.1 复权问题:转换的是“裸数据”
这是最重要的一点。通达信本地存储的.day文件是未经过复权的原始行情数据,即除权除息日的价格会出现跳空缺口。我们的转换程序只是忠实地将这些原始数字提取出来,不会进行任何前复权或后复权处理。如果你需要连续的价格序列用于计算收益率,必须在转换后另行进行复权处理。
提示:复权是一个独立的、复杂的步骤,需要用到除权除息信息。通常的做法是获取一份完整的除权除息表,然后对转换出来的原始CSV数据进行处理。也可以使用一些开源库(如
akshare)的复权接口,但需要注意数据源的一致性。在转换阶段,心里一定要绷紧这根弦:我拿到的是原始价。
4.2 数据完整性校验
转换完成后,不要急着用,先做快速校验:
- 时间范围检查:用Pandas打开几个不同股票的CSV,检查起始日期和结束日期是否合理。例如,检查最早的数据是不是在股票上市日之后,最新的数据是否更新到最近一个交易日。
- 价格合理性检查:随机抽查几天数据,用
(high >= low)和(high >= open)、(high >= close)、(low <= open)、(low <= close)这些基本逻辑规则进行验证。虽然原始数据出问题的概率低,但校验一下能防止因解析程序bug导致的数据错乱。 - 缺失值检查:检查是否有整行数据为NaN或0(尤其是成交额为0可能意味着停牌日,但价格不应全为0)。使用
df.isnull().sum()快速查看。 - 顺序检查:确认数据是否按日期严格递增(因为我们反转了列表)。
4.3 文件路径与编码的坑
- 路径中的空格和中文字符:如果源文件路径包含空格或中文,确保在代码中正确处理。
pathlib和open()函数通常能处理,但在某些老旧系统或复杂环境下,可能需要额外注意。 - 文件权限:通达信的
.day文件目录有时会被软件独占或设置权限。确保在通达信软件完全退出后再运行转换脚本,否则可能因文件被锁定而读取失败。 - 版本差异:虽然我们讨论了主流格式,但通达信软件历史上可能存在细微的版本差异。如果发现解析出来的价格明显不对(比如所有价格都异常大或小),首先怀疑
PRICE_SCALE常量设置错误,其次是字节序。可以尝试将格式字符串中的<(小端)改为>(大端)测试。
4.4 性能优化建议
当你需要处理全市场几千只股票、长达二十年的数据时,效率就很重要了。
- 向量化操作:上述代码是逐条解析的。对于单个文件,速度尚可。如果追求极致性能,可以考虑一次性读取整个文件字节流,然后用
numpy的frombuffer函数配合dtype定义结构化数组来一次性解析,速度会有数量级的提升。但这需要对numpy有更深的理解。 - 并行处理:股票文件之间是独立的,非常适合并行。可以使用Python的
concurrent.futures.ProcessPoolExecutor进行多进程转换,充分利用多核CPU。注意,并行时文件读写最好分散到不同磁盘或目录,避免IO成为瓶颈。 - 内存考虑:全市场数据全部读入内存可能会很大。上述代码是处理一个保存一个,内存友好。如果使用向量化方法,需要注意单个大数组的内存占用。
5. 从CSV到数据应用:构建本地数据管道的思路
转换出CSV不是终点,而是起点。接下来,我们可以基于这些干净的CSV文件,构建一个更自动化、更健壮的个人数据管道。
5.1 设计一个统一的数据存储层
不建议每次分析都直接读取成千上万个分散的CSV文件。一个更好的做法是,将所有股票的CSV数据导入到一个统一的数据库中。SQLite是一个完美的选择,它无需安装服务器,单个文件即可管理,并且支持完整的SQL查询。
你可以创建一个包含date(日期)、code(代码)、open、high、low、close、volume、amount等字段的数据表。然后编写一个脚本,遍历所有CSV文件,将其数据插入(INSERT)或替换(REPLACE)到数据库的相应位置。这样,你所有的日线数据都在一张表里,查询起来非常方便,例如:“获取2023年所有沪深300成分股的日收盘价”,一句SQL就能搞定。
5.2 实现增量更新
市场每天都有新数据。我们不可能每天全量转换所有文件。一个实用的方案是:
- 首次运行,全量转换并导入数据库。
- 此后每天,只处理更新过的
.day文件。如何判断?可以比较.day文件的最后修改时间mtime,或者更可靠的是,在数据库中记录每只股票最新的数据日期,然后去.day文件中解析出最新的几条记录,只补充数据库中缺失的日期。
这个过程可以脚本化,结合Windows的任务计划程序或Linux的cron,实现每日收盘后的自动数据更新。
5.3 与量化框架集成
有了结构化的数据库,你就可以轻松地与各种量化分析框架(如backtrader,zipline, 或国内的RQAlpha)对接。通常这些框架都支持从Pandas DataFrame或自定义数据源加载数据。你可以写一个通用的数据加载函数,根据策略需要的股票列表和时间范围,从你的SQLite数据库中快速查询并组装成框架要求的格式。
这样做的好处是数据主权完全掌握在自己手中。你清楚数据的每一个细节,知道它有没有复权、如何更新的,避免了因第三方数据源API变动或服务中断而导致的研究受阻。
回过头看,从通达信.day文件到CSV的转换,看似是一个简单的格式解析问题,实则贯穿了数据获取、解析、清洗、存储和管理的全流程。它强迫你去理解最原始的数据结构,在这个过程中建立起来的数据敏感性和处理能力,远比直接调用一个现成的get_price()API要宝贵得多。我的经验是,初期多花些时间在数据基础建设上,后期策略研究和迭代的效率会呈指数级提升。当你不再为数据问题分心,才能更专注于策略逻辑本身。