简介:这是一套面向量化开发者与股票数据分析爱好者的本地数据解析工具,针对大智慧新一代Level-2 V3.03.08.0801版本设计,可提取沪市、深市、板块及港股多市场数据,解决本地DAT文件难以直接读取与转换的问题。资源包共55个文件,约655KB,以C#源码(21个cs文件)为核心,辅以csproj工程、resx资源、doc说明文档、reg注册脚本及msi安装包等,涵盖FxjReader数据读取器、FxjConverter转换器、MainForm主界面与安装部署模块,开箱即用。工具可完整解析DAY.DAT、MIN.DAT、REPORT.DAT、STKINFO60.DAT等核心文件,覆盖代码表、日线、分钟K线、逐笔成交、除权分红、股本结构、财务报表、十大股东、基金净值及持仓组合等17类数据表,并内置FxjData2FinData转换程序,支持增量写入与文件占用时自动备份读取。字段映射说明明确标注dm、rq、kp、sl等字段的类型、偏移量与字节序规则。目前已有101人学习,适合量化开发、本地数据库构建与离线分析场景。
1. 大智慧新一代Level-2本地数据解析:从二进制文件到可用行情表
如果你手里有一份大智慧新一代Level-2的本地数据目录,却只能靠软件界面看盘,那这些数据基本等于锁在黑匣子里。Level-2 相比传统快照行情,多了逐笔委托、逐笔成交、委托队列和千档盘口,数据量和字段复杂度都上了一个台阶。很多做量化回测、盘口行为分析、甚至只是想把历史逐笔落成数据库的开发者,第一步就卡在“文件读不出来”上。这篇笔记讲的就是用 C# 把大智慧新一代 Level-2 的本地数据解析成结构化记录,包含源码思路和全字段说明。适合有 C# 基础、想自己掌控行情数据链路的从业者,也适合做上位机或数据中台的同行参考。核心不是调某个接口,而是把文件格式、字段偏移和解析流程讲透,让你能自己复现。
2. 先搞清楚Level-2本地数据长什么样:文件类型与字段布局
2.1 大智慧新一代Level-2落地的几种文件形态
大智慧新一代的 Level-2 数据在本地通常不是单一文件,而是按日期和品种分目录存放。常见形态包括:按市场代码分的日目录、按证券代码分的子目录,以及目录下的逐笔成交文件、逐笔委托文件、快照文件。不同版本在命名上会有差异,但结构逻辑一致:时间、市场、代码、类型四要素决定文件归属。
我一般先做一件事:把数据目录完整列一遍,统计文件扩展名和大小分布。这一步能快速判断你拿到的是压缩包解压后的原始文件,还是已经被软件整理过的缓存。原始文件通常是定长记录或带长度前缀的变长记录,缓存文件可能混有索引。下面这段代码用来扫描目录并输出文件类型统计,帮你建立第一手认知。
using System; using System.IO; using System.Linq; using System.Collections.Generic; class Level2FileScanner { static void Main(string[] args) { string root = @"D:\DZH_Level2\Data"; // 替换为你的数据根目录 if (!Directory.Exists(root)) { Console.WriteLine("目录不存在,请检查路径"); return; } // 按扩展名分组统计文件数量和总大小 var stats = Directory.EnumerateFiles(root, "*.*", SearchOption.AllDirectories) .GroupBy(f => Path.GetExtension(f).ToLower()) .Select(g => new { Ext = g.Key, Count = g.Count(), TotalMB = g.Sum(f => new FileInfo(f).Length) / 1024.0 / 1024.0 }) .OrderByDescending(x => x.TotalMB); foreach (var s in stats) { Console.WriteLine($"扩展名: {s.Ext,-10} 文件数: {s.Count,-8} 总大小: {s.TotalMB:F2} MB"); } } }这段代码的逻辑很直接:递归枚举所有文件,按扩展名聚合。参数上,root指向你的数据根目录,SearchOption.AllDirectories保证子目录也被扫描。输出结果里,如果看到大量.dat或.tmp,说明是原始落盘文件;如果看到.idx或.db,说明有索引或数据库缓存。注意,扫描大目录时可能耗时较长,建议先在小范围目录上跑通再全量执行。
2.2 逐笔成交与逐笔委托的字段偏移规律
Level-2 逐笔数据的核心字段包括:证券代码、交易所、成交时间(精确到毫秒或微秒)、成交价格、成交数量、买卖方向、买方委托序号、卖方委托序号。逐笔委托则多了委托类型、委托价格、委托数量、原始订单号。这些字段在二进制文件里通常按固定偏移排列,但不同数据版本会有微调。
常见做法是先用十六进制查看器打开一个文件,找到时间戳和价格的可读模式,反推字段宽度。比如时间字段可能是 4 字节的 Unix 时间戳,价格可能是 4 字节整数除以 10000。我一般会写一个小的探测脚本,把文件前几百字节按不同偏移打印出来,人工比对。
using System; using System.IO; using System.Text; class HexProbe { static void Main() { string file = @"D:\DZH_Level2\Data\SH\600000\20240102\trade.dat"; byte[] buffer = new byte[256]; using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read)) { int read = fs.Read(buffer, 0, buffer.Length); Console.WriteLine($"读取 {read} 字节"); for (int i = 0; i < read; i += 16) { // 打印偏移和十六进制 Console.Write($"{i:X4}: "); for (int j = 0; j < 16 && i + j < read; j++) Console.Write($"{buffer[i + j]:X2} "); // 打印可读字符 Console.Write(" "); for (int j = 0; j < 16 && i + j < read; j++) { byte b = buffer[i + j]; Console.Write(b >= 32 && b < 127 ? (char)b : '.'); } Console.WriteLine(); } } } }这段代码把文件前 256 字节按 16 字节一行打印,左侧是偏移,中间是十六进制,右侧是可读字符。参数file指向一个具体的逐笔成交文件。通过观察可读字符,你能快速定位到证券代码的 ASCII 片段,然后围绕它推断前后字段。注意,不要一上来就全文件读取,先用小缓冲区探测,避免内存浪费。
2.3 用C#定义强类型结构体映射二进制记录
一旦字段偏移确定,下一步就是用 C# 结构体或类来映射每条记录。推荐用StructLayout特性配合Marshal.PtrToStructure,或者直接用BinaryReader按字段顺序读取。前者适合定长记录,后者适合变长或需要灵活跳过的场景。
下面是一个逐笔成交记录的强类型定义和读取示例。假设每条记录固定 32 字节,字段依次为:时间戳(4字节)、价格(4字节整数,实际价格需除以10000)、成交量(4字节)、买卖方向(1字节)、买方委托号(4字节)、卖方委托号(4字节)、保留(11字节)。
using System; using System.IO; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct TickTrade { public uint TimeStamp; // Unix 时间戳,秒或毫秒 public int PriceRaw; // 原始价格,除以 10000 得实际价格 public int Volume; // 成交量 public byte Side; // 1=买,2=卖 public uint BuyOrderId; // 买方委托序号 public uint SellOrderId; // 卖方委托序号 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 11)] public byte[] Reserved; // 保留字段 } class TickReader { static void Main() { string file = @"D:\DZH_Level2\Data\SH\600000\20240102\trade.dat"; int recordSize = Marshal.SizeOf<TickTrade>(); byte[] buffer = new byte[recordSize]; using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read)) { while (fs.Read(buffer, 0, recordSize) == recordSize) { GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); TickTrade tick = Marshal.PtrToStructure<TickTrade>(handle.AddrOfPinnedObject()); handle.Free(); double price = tick.PriceRaw / 10000.0; Console.WriteLine($"时间:{tick.TimeStamp} 价格:{price:F4} 量:{tick.Volume} 方向:{tick.Side}"); } } } }这里的关键点:Pack = 1保证结构体按字节对齐,避免编译器插入填充字节。Marshal.SizeOf得到记录大小,用于循环读取。GCHandle固定托管数组以便PtrToStructure转换。参数上,PriceRaw除以 10000 是常见做法,但不同数据源可能用 1000 或 100,需要根据实际价格范围验证。注意,如果文件有文件头或记录间有分隔符,需要先跳过或按实际偏移读取。
3. 解析流程落地:从文件读取到结构化入库
3.1 按市场与日期组织解析任务
实际数据目录往往包含多个市场、多个日期、多个品种。直接全量遍历容易乱,我一般先构建一个任务列表:每个任务对应一个文件,包含市场、代码、日期、文件类型。然后按任务并行或串行解析。下面代码展示如何生成任务列表并过滤出逐笔成交文件。
using System; using System.Collections.Generic; using System.IO; using System.Linq; class TaskBuilder { public class ParseTask { public string Market { get; set; } public string Code { get; set; } public string Date { get; set; } public string FileType { get; set; } public string FullPath { get; set; } } static void Main() { string root = @"D:\DZH_Level2\Data"; var tasks = new List<ParseTask>(); // 假设目录结构:root/市场/代码/日期/文件名 foreach (var marketDir in Directory.GetDirectories(root)) { string market = Path.GetFileName(marketDir); foreach (var codeDir in Directory.GetDirectories(marketDir)) { string code = Path.GetFileName(codeDir); foreach (var dateDir in Directory.GetDirectories(codeDir)) { string date = Path.GetFileName(dateDir); foreach (var file in Directory.GetFiles(dateDir)) { string fileName = Path.GetFileNameWithoutExtension(file).ToLower(); string fileType = fileName.Contains("trade") ? "trade" : fileName.Contains("order") ? "order" : "snapshot"; tasks.Add(new ParseTask { Market = market, Code = code, Date = date, FileType = fileType, FullPath = file }); } } } } Console.WriteLine($"共生成 {tasks.Count} 个解析任务"); foreach (var t in tasks.Take(5)) Console.WriteLine($"{t.Market}/{t.Code}/{t.Date} -> {t.FileType}"); } }这段代码按四级目录结构生成任务,market、code、date从路径中提取,fileType根据文件名关键字判断。参数上,root是数据根目录,Take(5)只预览前五个任务。注意,如果目录层级不同,需要调整循环嵌套。生成任务列表后,可以按文件类型分别处理,逐笔成交和逐笔委托的解析逻辑不同。
3.2 逐笔成交解析的完整实现与字段校验
有了任务列表,逐笔成交解析可以独立成一个方法。核心步骤:打开文件、循环读取固定长度记录、转换为结构体、校验字段合理性、输出或入库。校验包括:价格是否在合理范围、成交量是否为正、时间戳是否在交易时段内。
using System; using System.IO; using System.Runtime.InteropServices; public class TradeParser { [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct TickTrade { public uint TimeStamp; public int PriceRaw; public int Volume; public byte Side; public uint BuyOrderId; public uint SellOrderId; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 11)] public byte[] Reserved; } public static void Parse(string filePath) { int recordSize = Marshal.SizeOf<TickTrade>(); byte[] buffer = new byte[recordSize]; int lineCount = 0; using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { while (fs.Read(buffer, 0, recordSize) == recordSize) { GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); TickTrade tick = Marshal.PtrToStructure<TickTrade>(handle.AddrOfPinnedObject()); handle.Free(); double price = tick.PriceRaw / 10000.0; // 简单校验:价格大于0,成交量大于0 if (price <= 0 || tick.Volume <= 0) continue; // 这里可以写入数据库或输出到控制台 if (lineCount < 10) // 只打印前10条作为示例 { Console.WriteLine($"时间:{tick.TimeStamp} 价格:{price:F4} 量:{tick.Volume} 方向:{tick.Side} 买序:{tick.BuyOrderId} 卖序:{tick.SellOrderId}"); } lineCount++; } } Console.WriteLine($"共解析 {lineCount} 条有效记录"); } }逻辑说明:循环读取固定大小缓冲区,用GCHandle固定后转换为结构体。校验部分过滤掉价格或成交量为非正数的异常记录。参数上,PriceRaw除以 10000 是假设,实际需要根据品种调整。注意,如果文件末尾有不足一条记录的数据,Read返回值会小于recordSize,循环自然结束。输出部分只打印前 10 条,避免刷屏。
3.3 逐笔委托与快照的差异化处理
逐笔委托比逐笔成交多了委托类型和原始订单号,字段长度可能不同。快照数据则是按时间间隔切片,包含买卖各十档或千档的价位和量。处理思路类似,但结构体定义和校验规则不同。下面给出逐笔委托的结构体示例,假设记录 40 字节。
[StructLayout(LayoutKind.Sequential, Pack = 1)] public struct TickOrder { public uint TimeStamp; public int PriceRaw; public int Volume; public byte Side; // 1=买,2=卖 public byte OrderType; // 1=市价,2=限价,3=本方最优 public uint OrderId; // 原始订单号 [MarshalAs(UnmanagedType.ByValArray, SizeConst = 22)] public byte[] Reserved; }对于快照,通常包含多个档位,每个档位有价格和量。可以定义嵌套结构体或数组。解析时注意档位数量是固定的,循环读取即可。参数上,快照的时间间隔可能是 3 秒或 1 秒,取决于数据版本。校验时关注买卖档位价格是否交叉、量是否非负。
4. 避坑与排查:Level-2解析中容易翻车的几个点
4.1 字节序与对齐问题导致价格变成天文数字
现象:解析出来的价格是 123456789 这种明显不合理的值。原因:二进制文件可能使用大端序,而 C# 默认按小端序读取;或者结构体对齐方式与文件不一致。解决:用BitConverter手动转换字节序,或者调整StructLayout的Pack值。我一般先用十六进制查看器确认价格字段的字节顺序,再决定是否反转。
4.2 文件头或分隔符未跳过导致记录错位
现象:解析几条后数据开始混乱,时间戳和价格完全对不上。原因:文件开头有文件头(如版本号、记录数),或者记录之间有分隔符,而代码直接从 0 偏移开始读。解决:先用探测脚本找到第一条有效记录的起始偏移,然后在FileStream上调用Seek跳过。常见文件头长度是 16、32 或 64 字节,需要实际验证。
4.3 时间戳精度混淆导致回测时间轴错乱
现象:逐笔成交的时间戳看起来是秒级,但实际数据是毫秒级,导致回测时同一秒内大量成交被错误排序。原因:时间戳字段可能是毫秒或微秒,而代码按秒处理。解决:观察时间戳数值范围,如果超过 1e12 可能是微秒,超过 1e9 可能是毫秒。根据交易所实际精度调整。我一般会取几条记录,用在线时间戳转换工具核对。
4.4 不同市场或品种的记录长度不一致
现象:解析上海市场的文件正常,解析深圳市场的文件就报错或数据错位。原因:不同市场的 Level-2 数据格式可能有差异,记录长度或字段顺序不同。解决:为每个市场单独定义结构体,或者在解析前先读取文件头中的记录长度字段。不要假设所有市场共用一套格式。
4.5 大文件一次性读取导致内存溢出
现象:解析几个 GB 的逐笔文件时程序崩溃或卡死。原因:使用了File.ReadAllBytes一次性加载整个文件。解决:改用FileStream配合固定大小缓冲区循环读取,每次只处理一条记录。内存占用与记录大小成正比,而不是文件大小。这是最基本但最容易忽略的一点。
5. 进阶技巧:把解析结果落到数据库并做快速验证
5.1 用SQLite批量插入提升入库速度
解析出来的记录如果逐条插入数据库,速度会非常慢。我一般用事务加批量插入,或者先写入 CSV 再用数据库工具导入。下面是一个用 SQLite 批量插入的示例,每 1000 条提交一次事务。
using System; using System.Data.SQLite; using System.IO; using System.Runtime.InteropServices; public class DbInserter { [StructLayout(LayoutKind.Sequential, Pack = 1)] public struct TickTrade { public uint TimeStamp; public int PriceRaw; public int Volume; public byte Side; public uint BuyOrderId; public uint SellOrderId; [MarshalAs(UnmanagedType.ByValArray, SizeConst = 11)] public byte[] Reserved; } public static void ParseAndInsert(string filePath, string dbPath) { using (var conn = new SQLiteConnection($"Data Source={dbPath};Version=3;")) { conn.Open(); using (var transaction = conn.BeginTransaction()) { using (var cmd = new SQLiteCommand(conn)) { cmd.CommandText = "INSERT INTO trades (ts, price, volume, side, buy_id, sell_id) VALUES (@ts, @price, @vol, @side, @bid, @sid)"; cmd.Parameters.Add(new SQLiteParameter("@ts")); cmd.Parameters.Add(new SQLiteParameter("@price")); cmd.Parameters.Add(new SQLiteParameter("@vol")); cmd.Parameters.Add(new SQLiteParameter("@side")); cmd.Parameters.Add(new SQLiteParameter("@bid")); cmd.Parameters.Add(new SQLiteParameter("@sid")); int recordSize = Marshal.SizeOf<TickTrade>(); byte[] buffer = new byte[recordSize]; int count = 0; using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { while (fs.Read(buffer, 0, recordSize) == recordSize) { GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned); TickTrade tick = Marshal.PtrToStructure<TickTrade>(handle.AddrOfPinnedObject()); handle.Free(); cmd.Parameters["@ts"].Value = tick.TimeStamp; cmd.Parameters["@price"].Value = tick.PriceRaw / 10000.0; cmd.Parameters["@vol"].Value = tick.Volume; cmd.Parameters["@side"].Value = tick.Side; cmd.Parameters["@bid"].Value = tick.BuyOrderId; cmd.Parameters["@sid"].Value = tick.SellOrderId; cmd.ExecuteNonQuery(); count++; if (count % 1000 == 0) { transaction.Commit(); transaction = conn.BeginTransaction(); } } } transaction.Commit(); } } } } }这段代码在循环内执行插入,每 1000 条提交一次事务。参数上,dbPath是 SQLite 数据库路径,表结构需要提前建好。注意,transaction在提交后需要重新开始,否则后续插入会报错。批量提交能显著减少磁盘 I/O,实测比逐条提交快一个数量级。
5.2 用聚合查询验证解析完整性
入库后,用几条 SQL 快速验证数据是否完整。比如统计每个交易日的记录数、检查价格范围、查看买卖方向分布。下面是一个验证查询示例。
-- 统计每日记录数和价格范围 SELECT date(ts, 'unixepoch') AS trade_date, COUNT(*) AS total, MIN(price) AS min_price, MAX(price) AS max_price FROM trades GROUP BY trade_date ORDER BY trade_date; -- 检查买卖方向分布 SELECT side, COUNT(*) AS cnt FROM trades GROUP BY side;第一条查询按日期分组,统计记录数和价格极值。如果某天记录数明显偏少,可能是文件缺失或解析中断。第二条查询检查买卖方向,正常情况买卖数量应该接近。如果严重失衡,可能是方向字段解析错误。这些查询能快速定位问题,不用逐条翻数据。
5.3 一个我常用的习惯:先跑通单文件再全量
每次拿到新数据,我不会直接全量解析。先挑一个品种、一个日期的单文件,跑通解析、入库、查询全流程,确认字段和校验逻辑无误,再扩展到全量。这个习惯帮我省了很多后悔药。Level-2 数据格式在不同版本间有差异,单文件验证是最快的试错方式。希望帮到你。
本文还有配套的精品资源,点击获取