拓冰建站拓冰建站
首页 / 资讯中心 / 正文

K线数据校验与复权处理:量化回测前必做的数据质量检查

先说个真事。去年有个读者给我看他的回测曲线MA5上穿MA10就在沪深300里选股年化收益标着480%。我看着那条45度角的资金曲线第一反应不是羡慕而是问他你的数据复权了吗他愣住了反问我复权是什么后来我帮他把数据做了前复权回测年化从480%掉到了31%。这个故事不是个例。几乎每隔一段时间就会有人带着“暴利策略”来找我诊断最后十有八九都出在数据上。Python把股票历史K线拿到手之后很多人直接读了CSV就算MA、算MACD、跑回测中间那段最关键的“数据质量处理”完全被跳过了。这篇文章不聊怎么爬K线也不聊怎么选股只聊一件被多数人忽略的事拿到K线之后从数据校验到量化回测这段路到底该怎么走。适合两类人看一是刚开始用Python做量化的朋友二是已经写了几个策略、但总觉得回测结果不真实的人。前者可以照抄后面的数据校验脚本后者可以对照检查清单排查自己的流程。保证全部是实操经验没有废话。1. 拿到K线后的第一件事不是算指标而是给数据做体检1.1 为什么数据校验比策略更重要很多人觉得数据校验是体力活策略才是核心技术。这个认知在我这儿恰恰是反的。策略写错了顶多是不赚钱数据是错的你会在一个根本不存在的规律上优化半年最后实盘亏了还不知道为什么。你想想均线是用收盘价算的MACD是用收盘价和EMA算的ATR是用最高最低算的。数据里多一行重复K线pct_change就会变成0信号判断就会出逻辑错误少几根K线MACD的金叉死叉位置就直接偏移。复数根K线错乱你的技术指标就会给出完全不同的买卖点。量化回测本质上就是“用过去的样本推断未来”样本本身脏了推断结论没有任何统计学意义。所以我的习惯是任何新数据源到手先不急着画K线先给数据做一次全面体检。体检不过关后面所有步骤都暂停。这一步花费的时间最多不超过半小时但它能帮你省掉后面至少三天排查诡异回测结果的时间。1.2 先从五个维度给K线做体检数据校验不是随便看一眼“有没有空值”就完了。针对股票K线数据我常年固定检查五个维度。维度检查内容常见异常时间完整性交易日是否连续、是否乱序缺交易日、时间顺序颠倒唯一性是否存在重复K线同一交易日出现多行价格逻辑OHLC之间是否自洽最高价低于开盘或收盘价值域合理性是否有0、负价格或极端突变价格为0、单日涨跌幅异常复权状态是否包含复权因子除权日是否跳空除权日出现超限跳空价格逻辑这一项很多新手会忽略。一根日K线由开盘、最高、最低、收盘四个价格组成任何一根正常K线都必须满足最高价开盘价、最高价收盘价、最低价开盘价、最低价收盘价。如果最高价比收盘价还低说明这一行数据在生成过程中就出了问题必须标记出来。值域合理性也很有门道。股票价格不可能为0或负值这个不用多说。单日涨跌幅超过11%需要警惕——沪深主板股票正常交易日涨跌幅限制是10%ST股是5%创业板和科创板是20%北交所是30%。如果你拉的是沪深主板日线某天涨跌超过11%还没被标记为异常要么是数据源自动做了复权处理要么就是数据本身有错。这两种情况需要分别处理。1.3 一个可以直接跑的“数据体检”脚本下面这个脚本我一直在用结构很简单你复制就能跑。假设你已经把K线存成了CSV包含date、open、high、low、close、volume这几列。import pandas as pd import numpy as np def load_kline(path): df pd.read_csv(path) df[date] pd.to_datetime(df[date]) # 统一排序防止数据源乱序 df df.sort_values(date).reset_index(dropTrue) # 重复日期只保留最后一条 df df.drop_duplicates(subset[date], keeplast) return df def check_kline(df, stock_code): report {} report[stock_code] stock_code report[rows] len(df) report[start_date] df[date].min().strftime(%Y-%m-%d) report[end_date] df[date].max().strftime(%Y-%m-%d) # 1. 时间连续性: 计算相邻交易日间隔 gaps df[date].diff().dt.days report[max_gap_days] int(gaps.max()) if len(gaps) 1 else 0 report[normal_gap_count] int((gaps 6).sum()) # 2. 重复值: 虽然load时去重了这里再检查一次原数据 report[duplicate_rows] int(df.duplicated(subset[date]).sum()) # 3. OHLC逻辑自洽 bad_high (df[high] df[[open, close]].max(axis1)).sum() bad_low (df[low] df[[open, close]].min(axis1)).sum() report[bad_high_rows] int(bad_high) report[bad_low_rows] int(bad_low) # 4. 非正价格 non_positive (df[[open, high, low, close]] 0).sum().sum() report[non_positive_price_rows] int(non_positive) # 5. 涨跌幅异常: 这里按主板10%上下浮动了1% pct df[close].pct_change() abnormal (pct.abs() 0.11).sum() report[abnormal_pct_rows] int(abnormal) # 输出报告 print( K线数据体检报告 ) print(f股票代码: {stock_code}) print(f数据行数: {report[rows]}) print(f时间范围: {report[start_date]} ~ {report[end_date]}) print(f最大间隔天数: {report[max_gap_days]}) print(f疑似停牌/缺失标记: {report[normal_gap_count]} 次) print(f重复行数: {report[duplicate_rows]}) print(fOHLC逻辑异常: {report[bad_high_rows] report[bad_low_rows]} 行) print(f非正价格: {report[non_positive_price_rows]} 行) print(f涨跌幅超限: {report[abnormal_pct_rows]} 行) return report df load_kline(600519.csv) report check_kline(df, 600519)跑完之后你会得到一份类似这样的报告。重点关注涨跌幅超限和OHLC逻辑异常。这两个指标如果大于0说明数据源有脏数据后面必须人工确认。最大间隔天数如果出现7天以上的情况通常是法定节假日或者长期停牌需要结合实际情况判断。2. K线数据常见污染源缺失、重复、乱序与复权2.1 交易日历对齐为什么不能用自然日判断缺失刚开始做数据校验时我踩过一个坑直接用自然日判断K线缺失。结果每周一都被误报成“缺数据”因为周六周日本来就没有K线。股票K线只在交易日产生周末和法定节假日没有数据是正常的不是缺失。正确做法是用交易所交易日历对齐。最稳的方法有两个一是用exchange_calendars这个库直接加载上交所或深交所的交易日历然后和你的数据做差集二是用“相邻交易日间隔”做粗筛查。日线数据相邻两根K线的间隔通常在1天到4天之间如果出现超过6天的间隔大概率是长假或者停牌。我个人更推荐第二种简单直接不依赖额外库。如果是分钟级K线判断逻辑要改成“相邻K线的间隔是否超过一个固定的分钟数”比如5分钟K线正常间隔就是5分钟午休时段需要单独跳过。分钟线的日历对齐更复杂建议直接引入交易所日历做精确匹配。2.2 缺失K线的处理填充、插值还是留空这是数据质量处理中争议最大的问题。K线缺了一段到底要不要补我试过三种方案结论很明确默认留空不要用插值谨慎使用向前填充。原因是K线代表的是真实成交价格那段交易时间没有成交就是没有成交。插值出来的价格在现实中根本不存在不管是线性插值还是多项式插值本质上都是在“编造行情”。向前填充适合那些需要连续数据的指标计算场景但它改变不了“这段K线不存在”的事实。更好的办法是在指标计算层面对缺失做处理。pandas的rolling窗口有个min_periods参数可以指定最少需要多少个有效值才计算结果。比如一只股票停牌了一周你计算20日均线时窗口里只有10个有效值这时候min_periods10就只返回有效均线而不是硬拿一个错误值当均线。# 停牌期间数据缺失但均线仍然能基于有效数据计算 df[ma20] df[close].rolling(window20, min_periods10).mean()min_periods的设置需要结合策略逻辑。做趋势跟踪的可以放宽到10做高频统计的建议严格要求满窗口。这里没有标准答案但原则是宁可让指标出现空值也不能让错误数值混进信号判断。2.3 复权处理前复权、后复权与不复权的使用场景复权是K线数据处理里最绕、也最关键的一环。说个最简单的例子一只股票从10元变成5元不一定是股价腰斩也可能是10送10除权。如果不做复权处理你的均线系统会看到一根“假暴跌”大阴线直接触发止损信号如果做了前复权这根大阴线就会变成一条平滑的窄幅波动。三者的区别和适用场景我整理成一张表类型价格特征适用场景不复权历史价格是当时真实价格除权日有大跳空看真实成交价、计算实际仓位价值前复权历史价格被调整到当前视角价格连续技术指标计算、信号回测后复权最新价格是真实价格历史价格被调整长期收益率计算、绩效归因很多人分不清前复权和后复权我自己总结的口诀是前复权看形态后复权算收益。回测策略时用前复权因为技术指标依赖连续的价格序列计算一只股票从上市到现在到底赚了多少倍时用后复权因为后复权的终点就是真实价格收益映射最直观。判断数据源是否已经复权有个很实用的技巧看除权日前后两天的价格变化。如果某天跌幅接近-8%到-10%但当天并没有重大利空公告那大概率是除权除息导致的“假暴跌”说明你的数据很可能没复权。数据源一般会提供一个factor复权因子字段用原始价格乘以复权因子就能得到复权后的连续价格。# 前复权价格计算示意 df[adj_close] df[close] * df[factor] # 复权后的日收益率更接近真实收益 ret_adj df[adj_close].pct_change()3. 量化回测里最隐蔽的数据质量陷阱3.1 前视偏差让信号永远慢一步前视偏差是回测数据质量里最高频、也最致命的问题。通俗点说就是你用了“当时根本不可能知道的信息”来做决策回测结果自然好得离谱。最典型的错误是这样的用当天的收盘价计算均线金叉信号然后假设当天收盘价就成交了。这在实盘里做不到因为收盘价只有收盘后才知道信号也只能在收盘后生成、次日执行。如果你在数据上不做处理回测收益会高到不真实。解决办法是让信号和成交严格错开一个周期df[signal] (df[ma5] df[ma20]).astype(int) # 关键position在下一根K线才生效避免前视 df[position] df[signal].shift(1).fillna(0) # 策略收益 昨日持仓状态 * 今日收益率 df[strategy_ret] df[position] * df[close].pct_change()同样的逻辑适用于止损、止盈、调仓。凡是信号计算涉及当根K线数据的执行统一放到下一根K线。这个顺序错位之后回测结果会立刻回归理性。我见过太多人回测年化百分之几百其实就只是少一行shift。3.2 除权除息制造出来的“暴跌”假信号除权除息造成的跳空如果不处理会被策略误判成暴跌信号。举个例子一只股票除权前收盘10元10转10后开盘价变成5元。在不复权的K线上这就变成了一根跌幅50%的巨阴线均线立马拐头MACD立马死叉各种止损条件全面触发。但真相是公司经营没有任何变化股价也没有真正下跌。这只是因为总股本增加了每股价格等比例下调。复盘的时候你会看到策略在某个平淡无奇的交易日突然清仓手续费和滑点成本白白亏了一笔。解决办法有两个方向。第一回测前统一用前复权数据这是最省事的方案。第二如果你必须用不复权数据做回测至少要在收益计算环节用复权因子修正日收益率。第二种方式更接近真实交易因为下单价格是不复权的真实价格但收益率序列必须用复权收益。3.3 股票池、时间范围和幸存者偏差幸存者偏差是数据质量里最容易被忽略的“隐形杀手”。很多数据接口默认只返回当前仍在上市的股票这意味着那些已经退市、股价归零的股票在你的历史数据里根本不存在。你拿现在的股票池去回测五年前等于自动过滤掉了大量亏损样本收益自然虚高。我自己踩过这个坑后现在做历史回测会刻意确认三件事。第一股票池是否包含退市股票如果数据源没有退市股就缩短回测区间或者明确风控假设第二回测区间是否经过了完整的牛熊周期只取2019到2020年的数据回测收益好看没有意义第三是否模拟了涨跌停无法成交、停牌无法买入等现实约束如果信号触发当天股价直接涨停你根本买不进回测必须把这个仓位标记为“未成交”。这些约束不处理回测结果只是一个数字游戏。真正的量化回测要模拟的是“可成交的未来”不是“完美的过去”。4. 从数据校验到回测一套可复用的数据质量检查清单4.1 定义数据质量评分模型校验是手段最后还是要落到“这份数据能不能用”的判断上。我习惯给数据打分参考了数据管理行业常用的一些评估维度简化成了五项。维度权重评分标准完整性30%实际K线数 / 应交易天数越接近100%越好准确性30%OHLC逻辑异常行数、非正价格行数是否为0一致性20%与另一数据源抽样对比关键日期价格是否一致可复制性10%同一份数据能否稳定重放字段是否固定及时性10%数据是否更新到最近交易日总分低于85分我基本不会拿去做策略研究。90分以上的数据才敢进入回测流程。打分过程不复杂就是把这五个维度的检查结果量化权重自己按需调整。对分钟级别的高频策略及时性和准确性的权重应该调高对日线级别的长周期策略完整性和一致性更关键。4.2 检查清单速查表下面这份清单我贴在工位边上每次接新数据源都照着过一遍。检查项检查方法通过标准时间范围完整max(date) - min(date) 与预期交易日比对覆盖目标区间交易日无缺失相邻日期间隔超出阈值时人工确认长间隔仅为节假日或停牌无重复K线df.duplicated(subset[date])重复数为0无乱序按date排序后检查diff是否为正无负数间隔OHLC逻辑自洽high max(open, close), low min(open, close)异常数为0价格无异常open/high/low/close 0非正数为0涨跌幅合理单日涨跌幅是否超出板块限制超限行数需人工确认复权状态明确检查除权日附近是否有跳空跳空可解释或已被复权成交量有效volume 0停牌除外0成交量已标记不要觉得这些检查繁琐。我统计过一套日线数据走完这个清单通常不超过五分钟。五分钟换来的是后面策略调试阶段不被脏数据折磨这笔时间花得太值了。4.3 清洗后的存储结构数据校验和清洗做完不要直接存回CSV。CSV文件没有schema约束重复读入时类型容易丢失而且越大的数据量读取越慢。我个人的做法是统一转成Parquet格式配合pandas可以直接读写占用空间比CSV小一半以上读取速度快好几倍。推荐的存储字段结构如下字段名类型说明datetimedatetime64交易时间日线精确到日期openfloat开盘价highfloat最高价lowfloat最低价closefloat收盘价volumefloat成交量amountfloat成交额factorfloat复权因子is_restoredbool是否已经复权标记# 清洗完成后统一存储 df.to_parquet(600519.parquet, indexFalse) # 后续回测直接读取速度明显优于CSV df pd.read_parquet(600519.parquet)这个字段设计的好处是原始价格和复权因子都保留了后续你想用前复权、后复权还是不复权都可以一键切换。很多数据源不会自动给你factor字段务必要在清洗阶段补全不然后面做复权处理又要重新拉数据。5. 常见问题与排查技巧实录5.1 典型问题速查表实际干活过程中数据质量问题往往会伪装成“策略问题”出现。下面这个速查表记录了我这些年遇到的高频异常和排查方向。现象真正原因排查方法回测收益高得离谱前视偏差或未复权检查signal是否shift、除权日是否跳空均线频繁闪断停牌期数据缺失导致窗口断裂检查rolling是否设置min_periods信号频繁翻转重复K线使pct_change出现0值用duplicated检查重复行回测和实盘差距大成本假设不足或数据源不一致提高手续费和滑点重测特定日期策略异常亏损除权除息造成的假跳空核对除权日前后复权因子股票池收益虚高幸存者偏差确认是否包含退市股票每次遇到回测结果不合理第一反应不要改策略先跑一遍数据体检脚本。我自己的经验是超过一半的“策略问题”其实是数据问题。你花一天时间改策略参数不如花半小时查数据质量。5.2 一次“重复K线”引发的回测事故最后分享一个我印象深刻的真实案例。有一次我在复盘一只股票的MACD策略回测结果显示该策略在一个月内交易了24次手续费几乎吞掉了所有收益。第一反应是策略参数太敏感于是调整了三次参数问题依旧。后来无意间跑了一次数据体检发现这只股票在2019年6月28日出现了两行完全相同的K线。问题就出在这里重复K线导致当天的pct_change变成0MACD的DIF和DEA在这个位置出现了极其微小的异常交叉策略就误以为出现了金叉死叉的连续信号触发了一连串的虚假交易。排查过程其实很简单。先用df[df.duplicated(subset[date], keepFalse)]筛出重复行然后对比数据源的增量接口发现是数据同步时重复推送了一根K线。解决方案也很粗暴——drop_duplicates去重再加唯一索引防止后续重复写入。这个案例给我的警醒是数据体检不是一次性工作而是每次拉取数据后都要执行的习惯。哪怕数据源很稳定增量更新也可能出现重复推送、字段错位等问题校验脚本的成本极低收益却是实打实的。我自己到现在拿到任何新数据源第一件事仍然是跑一遍check_kline_data不过关坚决不往下走。见过太多人花一个月写策略却不肯花一天检查数据这个顺序一旦搞反后面的时间基本都是白费。数据校验这件事单看每一行都不难难的是把它变成回测前的一个固定动作。如果你正好准备拿K线跑策略建议先把上面的脚本跑一遍——哪怕结果显示数据没问题你在写策略时也会踏实很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门