ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从K线数据校验到量化回测:Python数据质量实战指南

从K线数据校验到量化回测:Python数据质量实战指南 用Python获取股票历史K线门槛其实比多数人想象的低得多但从拿到K线到真正跑通量化回测中间隔着数据校验这道坎。我见过不止一个朋友代码写得挺顺策略逻辑也有模有样结果回测收益曲线一片红实盘一用就露馅——最后排查下来问题全出在数据质量上。这篇文章就围绕“从数据校验到量化回测”这条线讲清楚K线数据到手之后到底该怎么检查、怎么清洗、怎么存储、怎么喂给回测引擎以及我自己踩过的那些和数据有关的坑。适合刚开始写量化策略、又不想在数据上翻车的Python使用者。1. 拿到K线先别急着写策略数据质量问题的真实代价1.1 一次“完美回测”背后的数据陷阱有次我帮朋友排查一个均线策略逻辑很简单5日均线上穿20日均线买入下穿卖出。他从数据接口下载了一支股票从2015年到2020年的日K直接拿不复权数据跑回测。结果年化收益超过40%最大回撤还不到10%。他特别兴奋打算上实盘。我扫了一眼他的回测日志发现一个问题每次除权除息日附近策略都会出现一次“神奇”的买卖信号而且收益特别集中。原因不复杂不复权数据在除权日会有一个巨大的价格跳空均线系统会把这种跳空当成趋势拐点误判成交易信号。这类问题不会让程序报错策略也能正常跑完但结果完全失真。数据校验的意义就在这里不是为了走一个流程上的过场而是决定回测结论能不能信。后面我们聊的所有检查项、清洗逻辑、存储设计本质上都是在回答一个问题——这份数据能不能真实反映市场当时发生的交易行为。1.2 K线数据质量会从哪些环节悄悄流失很多人以为数据质量只是“数据有没有缺失”实际上它是一整套链路问题。我在实际项目中总结过数据质量至少会从四个环节流失数据环节常见质量问题假如不注意的后果数据获取缺行、重复行、时间戳错乱、字段单位不统一指标计算错位信号乱跳复权处理前复权/后复权混用复权因子不保存收益率计算失真回测结果不可复现预处理停牌未处理、涨跌停未标注、股票池有幸存者偏差回测过度乐观实盘对不上存储设计只存复权价、增量更新覆盖原始数据历史信号无法复现排查成本极高这四个环节里任何一个出了问题最终都会反映在回测曲线上。麻烦的是数据问题产生的回测结果往往不是“报错式”的坏而是“合理但虚假”的好——策略看起来有效实际上在错误数据上自嗨。这也是为什么我强烈建议K线数据拿到手之后先别急着写策略先按下面这套流程给数据做一遍体检。2. K线数据校验的完整链路先给数据做体检2.1 第一道检查缺失、重复与交易日历对齐数据到手的第一件事不是算指标而是先把DataFrame的索引、列名、数据类型统一起来。我习惯把所有数据源的字段名映射成同一套规范trade_date、open、high、low、close、volume、amount这样后面的脚本才不用给每个数据源单独适配。去重和缺失检查可以放在一起。这里有一个特别容易踩的坑很多新手用pd.bdate_range来生成交易日序列然后跟实际K线数据做对比结果发现大量“缺失日”。原因很简单bdate_range只排除了周末但A股还有春节、国庆等法定节假日这些日子不是周末但也不开盘。正确做法是用交易所的交易日历或者从复权因子表、指数行情里反推一段时间的实际交易日。import pandas as pd def check_kline_dates(df, trade_calendarNone): issues [] df df.sort_values(trade_date).drop_duplicates(subset[trade_date]) if trade_calendar is not None: # trade_calendar 是包含 is_open 标记的交易日历 trade_dates trade_calendar[trade_calendar[is_open] 1][cal_date] missing trade_dates.difference(df[trade_date]) issues.append(f按交易日历检查缺失 {len(missing)} 个交易日) else: # 没有日历兜底时至少检查索引是否有序、有无重复 if df[trade_date].is_monotonic_increasing is False: issues.append(trade_date 未按升序排列) # 重复检查 dup_count df[trade_date].duplicated().sum() if dup_count 0: issues.append(f存在 {dup_count} 条重复日期记录) return issues, df缺失日期还要细分两类一类是停牌一类是真正漏数。停牌是市场行为处理方式不是补数据而是在回测时把停牌日标记为“不可交易”漏数是数据源的问题需要重新拉取或换源。区分方法也不难拿该股票的复权因子数据、指数当日行情做交叉验证如果当天指数在交易、其他股票也有K线只有这一只缺大概率是漏数需要补。2.2 第二道检查价格与成交量的合理性日期问题看完之后我通常会写一组规则去扫价格和成交量字段。很多脏数据用肉眼看不出来但规则一跑就现原形价格必须大于0尤其是low和close不能出现0或负数high必须大于等于lowopen和close理论上都应该落在high和low的区间内成交量、成交额不能为负成交量单位必须统一有的数据源返回手有的返回股1手等于100股未复权数据的单日涨跌幅超过交易所涨跌停限制的主板10%、创业板和科创板20%大概率是除权日没标注或者数据源拼错行成交额与成交量、均价之间有基本的勾稽关系成交额约等于成交量乘以均价如果数据里没有均价可以用(high low close) / 3做近似验证偏差超过一个数量级就要怀疑单位错了def check_price_volume(df): errors [] if (df[high] df[low]).any(): errors.append(存在 high low 的记录) if (df[low] 0).any(): errors.append(存在非正价格) if ((df[open] df[high]) | (df[open] df[low])).any(): errors.append(open 超出当日高低价区间) if ((df[close] df[high]) | (df[close] df[low])).any(): errors.append(close 超出当日高低价区间) if (df[volume] 0).any(): errors.append(存在负成交量) return errors这里要提醒一句不要看到异常就急着删。数据异常有两种可能一种是数据源错了一种是市场特殊状态。比如某些新股上市首日没有涨跌幅限制单日涨幅就可能超过20%再比如长期停牌后复牌的股票复牌首日也可能出现极端涨跌。最好的做法是把异常记录输出到报告里逐条人工确认而不是一刀切删掉。2.3 第三道检查复权方式的选择与校验复权是K线数据处理里最绕不开、也最容易出错的一环。先理顺三个概念不复权就是原始成交价除权除息日会留下价格跳空缺口。它不适合直接拿来算均线、MACD这类技术指标因为跳空会被误判成趋势。前复权保持最新价格不变把历史价格按复权因子向下调整。它的问题是每次公司分红除权之后整个历史价格序列都会被重写一遍所以你今年1月下载的前复权数据和5月下载的同一段历史前复权数据价格是会不一样的。后复权保持最早价格不变把后续价格向上调整序列不会因为新除权而重写适合做长期趋势分析。回测场景下我一般用前复权数据做技术指标和信号计算但一定会保留一份“不复权原始价 复权因子”的组合。复权因子的关系可以简单理解为后复权价等于原始价乘以复权因子前复权价则是在此基础上做一个整体缩放让最新价格对齐真实价格。校验复权数据是否正确的办法也简单对前复权数据计算每日收益率在除权除息日附近收益率不应该出现脱离大盘和个股基本面的巨大跳变。如果发现某个除权日前后两天收益率出现几十个百分点的异常波动那基本可以断定复权处理有问题要么是因子算错要么是把复权数据又复权了一次。2.4 第四道检查时间戳、时区与交易时段日线数据的时间戳问题相对少但如果你开始处理分钟线、小时线时间这块的坑一个接一个。不同数据源返回的时间戳含义不太一样有的直接给你北京时间有的给你UTC时间还有的会把日期和时间拼成字符串解析格式一个没对齐整批数据就乱了。分钟级别数据还要注意集合竞价的问题。A股开盘价是在9:25集合竞价产生的所以9:30这根分钟K线其实包含了9:15到9:25的委托信息如果你按自然时间切分可能会把集合竞价数据切到前一天或者跟盘中数据混在一起。更隐蔽的是时区问题如果数据源服务部署在境外返回的时间戳看起来是“正确”的但实际上是UTC时间转换成本地时间之后整个交易时段会偏移8个小时回测时等于在用未来数据做交易决策。我这边的习惯是进入存储层之前所有时间统一转换成东八区时间字符串格式统一为YYYY-MM-DD HH:mm:ss日线统一为YYYY-MM-DD每个文件里额外记录一个timezone字段防止后续读取时按错误时区解析。3. 清洗后的K线数据应该怎么存数据层设计经验3.1 存储格式怎么选Parquet 还是 HDF5数据清洗完之后存储设计往往被忽略但它直接影响回测速度和可复现性。三种常见格式我都在项目里用过简单做个对比存储格式优点缺点适用场景CSV通用、可读、方便核对体积大、读写慢、无数据类型临时交换、肉眼检查Parquet列式压缩、读取快、pandas原生支持好增量追加写不方便按标的或按日期分区的批量回测HDF5支持随机访问、单文件管理海量数据多进程写入有锁、文件易损坏分钟级数据密集存储我的个人选择是几百只股票的日K数据用Parquet 按股票代码分区的目录结构读起来快排查时可以直接打开单个文件看内容非常直观。CSV我只用来做数据交换比如从某个接口下载到临时目录后先看一眼格式再转成Parquet归档。HDF5适合数据量大到单只股票分钟线就有几百万行的场景但要注意用h5py或tables写入时不要多个进程同时写同一个文件否则文件损坏的概率会明显上升。3.2 字段设计与索引设计回测性能从哪来K线数据落到存储里时我给每张表设计的字段基本是固定的额外增加几个回测时需要但数据源不一定提供的标记字段trade_date交易日期日线用日期分钟线用日期时间open、high、low、close四个基础价格volume成交量统一单位为股amount成交额单位元adj_factor复权因子suspended是否停牌1停牌0正常limit_up、limit_down是否涨停、是否跌停1是0否vwap成交均价有则保留没有则用成交额除以成交量反算索引设计上回测时经常要做两种操作一种是按时间切片另一种是按股票分组。如果只做单标的策略用trade_date做DatetimeIndex最简单如果要同时跑几千只股票我建议在内存里用pd.MultiIndex第一层是时间第二层是股票代码这样pandas在做向量化运算时能自动对齐索引省掉大量显式循环。预计算标记字段是我特别推荐的一个习惯。limit_up、suspended这类字段看起来简单但如果等到回测时再临时判断每根K线都要做一次逻辑判断速度慢不说还容易在信号函数里写出重复代码。我通常在清洗阶段就把这些标记算好存起来回测引擎读取后直接用逻辑清晰也跑得快。3.3 保存原始数据 复权因子的独立管理这是我在数据存储上最想强调的一点永远保留一份“不复权原始数据”加“独立复权因子”不要只存前复权价格。原因回到前面说的前复权特性——它会随着每次新的分红除权而改写历史价格。假设你3月份下载了一段前复权数据跑了回测记录了策略信号到了7月份这只股票又分了一次红数据源自动重新计算了复权因子历史价格都变了。此时你再跑同一段策略信号可能会不一样但你已经不知道到底哪个结果是“正确”的了。这种回测结果不可复现的问题在实盘分析里非常致命。我个人的目录结构长这样data/ raw/ SH600000.parquet # 不复权原始价 adj_factor SZ000001.parquet adjusted/ SH600000.parquet # 按固定截止日期做的前复权快照raw目录里的文件基本只追加、不修改adjusted目录里是某一次生成的前复权快照文件元数据里会记录生成日期和复权截止日期。这样不管后续数据源怎么更新我都能回到“某一天跑出的结果”对应的数据版本上排查问题的时候心里有底。4. 从清洗数据到量化回测必须绕开的三个暗坑4.1 未来函数数据本身干净代码却在偷看未来数据清洗得再干净如果回测代码里存在未来函数结果一样是废的。所谓未来函数就是策略在某个时间点使用了当时还不存在的信息。这是新手最容易犯、也最难察觉的错误。最典型的场景是均线策略。有人写df[ma5] df[close].rolling(5).mean() df[signal] df[close] df[ma5] df[ret] df[close].pct_change() df[strategy_ret] df[signal] * df[ret] # 错误问题在于df[signal]用的是当天的收盘价和当天均线算出来的但df[ret]也是当天的收益率。现实中你只能在收盘后知道当天收盘价而当天收益已经发生了你用收盘后的信号去“交易”当天的收益等于让信号穿越回了开盘之前。正确做法是收盘后产生信号次日开盘执行df[ma5] df[close].rolling(5).mean() df[signal_today] df[close] df[ma5] # 当天收盘后产生信号 df[position] df[signal_today].shift(1) # 次日才持仓 df[ret] df[close].pct_change() df[strategy_ret] df[position] * df[ret] # 次日收益生活化理解就是天气预报说明天会下雨你今天晚上把伞放进包里没问题但如果你用“明天实际下了多少雨”的数据来决策今天该不该带伞这就叫未来函数。回测里稍微一个shift方向写反就会让策略“偷看”到未来收益曲线当然好看。4.2 幸存者偏差你的股票池可能已经被“筛选”过另一个数据层面的暗坑是幸存者偏差它发生在股票池构建环节。如果你回测时用的是“当前仍然上市”的股票列表那些退市的、被ST长期停牌的股票天然被排除在外了历史回测业绩会系统性高估。比如你在2024年用最新代码表回测2015年的策略这个代码表里包含的是2024年还活着的股票而2015年活跃但后来退市的股票全都不在池子里。那你的回测实际上是在“已经成功活下来”的股票里选标的胜率当然高。处理方式不复杂但需要数据支持# 假设 all_securities 里有 list_date 和 delist_date def is_tradable(symbol, date): if date list_date[symbol]: return False if delist_date[symbol] is not None and date delist_date[symbol]: return False return True用上市日期和退市日期动态过滤股票池保证在回测的每一个时间点股票池里只包含“当时真实存在且可交易”的标的。这需要数据源提供历史证券列表如果接口给不了至少也要做一层近似处理回测结束后分析一下收益贡献中有多少来自后来退市的股票如果占比异常低就要警惕幸存者偏差。4.3 滑点、手续费与最小变动价位数据干净了模型还会骗你数据没问题、代码没有未来函数还有一个隐藏因素会让回测失真——交易成本被低估。很多回测框架默认手续费可以配参数但默认值往往是零或者极低滑点更是直接被忽略。真实交易里你不可能总按收盘价成交冲击成本和滑点都会侵蚀收益。A股当前的交易成本主要包括佣金、印花税和过户费。佣金通常按成交金额的一定比例收取有最低收费标准印花税只在卖出时收取过户费按成交数量收取。回测时这些参数不要写死应该放到配置里因为税费政策会调整写死在代码里以后改起来很麻烦。滑点模型可以从简到复杂分三档固定滑点每次成交价格在信号价格基础上偏移固定金额比如一个最小变动价位0.01元比例滑点按成交价格的一定比例偏移比如万分之二冲击成本模型根据成交量和流通盘估算额外成本适合大资金测试另外要注意最小变动价位。A股股票最小变动价位是0.01元成交价必须是0.01的整数倍你在回测里把成交价设为任意浮点数就会出现现实中不可能出现的成交价这在小级别资金策略里影响不大但在高频或低价股策略里会明显失真。5. 数据质量的实测验证方法让脏数据自己现形5.1 写一个K线数据质量检查脚本可以直接抄前面讲了那么多理论和原则落到实操上最好用的方式就是写一个检查脚本把数据质量检查自动化。我自己的脚本核心是一个validate_kline函数输入DataFrame输出问题列表def validate_kline(df): issues {} # 重复检查 dup df.duplicated(subset[trade_date]).sum() issues[重复日期] dup # 缺失检查用交易日历 if trade_cal in globals(): missing trade_cal.difference(df[trade_date]) issues[缺失交易日] len(missing) # 价格逻辑检查 issues[high_lt_low] (df[high] df[low]).sum() issues[open_out_of_range] ((df[open] df[high]) | (df[open] df[low])).sum() issues[close_out_of_range] ((df[close] df[high]) | (df[close] df[low])).sum() issues[non_positive_price] (df[close] 0).sum() # 成交量检查 issues[negative_volume] (df[volume] 0).sum() # 复权收益率异常 adj_close df[close] * df[adj_factor] ret adj_close.pct_change() issues[extreme_return] (ret.abs() 0.21).sum() # 超过20%涨幅阈值需要人工确认 return issues这个脚本会生成一份报告我会把它作为数据入库之前的强制门禁。任何一个检查项出现异常且不能解释的数据就不能进入回测流程。解释的办法有两种一种是通过公告、除权除息数据确认是市场特殊状态另一种是直接丢弃找数据源重新拉取。5.2 用收益率序列做交叉验证检查完字段本身之后我还会做一步“收益率序列合理性”验证。这步算是对整份数据做最终体检因为价格数据是否有隐藏问题最终都会反映在收益率上。具体做法是把复权后的收盘价算成日收益率序列然后看几个指标。第一极端值数量。A股股票在非极端行情下的日收益率极少超过20%如果出现大量30%、50%的收益我首先会怀疑是复权没做对或者价格单位有问题而不是觉得“行情真好”。第二连续完全相同值的出现频率。如果某段区间内每天的收益率几乎一模一样有可能是数据源对停牌区间做了平滑填充这类数据在回测中会制造出虚假的低波动特征。第三与指数收益的相关性。个股和大盘指数在某段时间内相关性不应该突然变为负且绝对值极大如果出现这种背离可以回溯K线数据大概率找得到错误记录。这种交叉验证没法用一条规则覆盖所有问题但能帮你快速定位“哪些股票需要人工复查”。我的经验是几百只股票的数据半分钟跑完校验脚本输出一个需要复查的股票名单这比盲目信任数据源可靠得多。5.3 样本外回测与策略信号复现最后一道验证是把干净的数据真正放进回测里跑一遍但要用“样本外”的思路来做。很多人把全部历史数据跑一遍回测觉得收益曲线好就说明策略有效这其实是不够的。规范的流程是把数据切成三段训练段用来开发策略验证段用来调参样本外段用来做最终验证。样本外数据在策略开发过程里绝对不能碰否则你的一切“调优”都等于在数据上过拟合最终结果自然好看但不可靠。另一个我强烈推荐的习惯是信号复现测试。选择几个特征明确的历史日期比如某一天的均线金叉、某一天的涨停突破用手工方式算出理论上应该产生的信号然后跑一遍程序看输出信号是否一致。如果历史行情是“标准答案”而程序输出跟标准答案对不上那问题大概率还是出在数据处理层——要么是复权价格跟当时真实行情对不上要么是时间对齐出了问题。这类测试看起来简单但能在策略正式上线前挡住大量低级错误。我在实际项目中至少见过三次这样的案例策略逻辑本身没问题就因为某个数据字段在特定时间段解析错了导致回测结果跟预期完全不符如果只盯着收益曲线看根本发现不了问题所在。6. 个人实操中的几个习惯数据版的“防呆设计”6.1 数据快照与哈希校验做回测时间长了之后我养成了一个“防呆”习惯每次回测之前把原始数据文件的哈希值记录下来。数据文件一旦变化哈希值就会变这样就能知道“这一次回测和上一次回测”之间数据有没有被更新过。很多次我查一个策略的收益为什么忽然变了查到最后发现不是策略代码出了问题而是数据源把历史数据刷新了前复权价格整体变了信号自然跟着变。如果一开始记录过哈希值这个问题一眼就能定位。6.2 先跑一条买入持有基准线每次拿到一份新数据我不会直接跑复杂策略而是先算一条“从第一天买入、一直持有到最后一天卖出”的基准净值曲线。这条曲线是判断数据合理性的最直观工具。如果一份数据连买入持有基准线都画得奇奇怪怪出现明显的锯齿状跳变说明数据里还有没处理干净的问题比如复权错误、停牌日期没填充。如果基准线很平滑再跑复杂策略才有意义。6.3 增量更新时格外小心前复权数据如果你的数据是每天自动更新的增量更新时前复权历史数据会跟着最新的分红除权事件变化。这里有个隐藏风险你昨天跑出了一个策略信号今天因为复权因子更新同一段历史的信号可能就变了而你昨天记录的交易计划已经不可复现了。我现在如果做以日线为频率的策略会固定以某个截止日期的前复权数据作为“决策基准”每天更新数据时只更新最新K线尽量不回头改写历史复权价。这样做牺牲了一点理论上的价格连续性但换来的是策略信号的稳定性和可复盘性对我来说值得。6.4 数据源不要只信一家最后一点经验是关键数据一定要用两个独立来源做抽检。我并不是让你所有行情都买两套而是至少对除权除息日、停牌复牌日、上市退市日这些关键时间点的数据做一次交叉比对。这些日期直接影响复权因子和股票池构建一旦错了一个整个回测逻辑都会被带偏。我自己就遇到过不同数据源对某只股票除权日登记不一致的情况后来核对交易所公告才发现是其中一个数据源把除权日当成了股权登记日。这种错误不交叉验证根本发现不了。数据质量控制这件事看起来琐碎远没有写策略那么有成就感但它恰恰决定了回测结果到底是一次有效模拟还是一堆自欺欺人的数字。我自己在数据上吃过不少亏现在养成的一切习惯说到底都是在为自己的策略结果负责。回测可以反复跑但实盘账户经不起数据挖的坑。
返回列表