ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

量化回测前必做:K线数据清洗与预处理全流程解析

量化回测前必做:K线数据清洗与预处理全流程解析 做量化这几年我发现自己最常被问到的不是“策略怎么写”而是“数据拿到手之后到底该怎么处理”。很多人从Tushare、AKShare或者其它数据源把历史K线下载下来看一眼DataFrame有几千行就急着算指标、跑回测结果策略在回测里收益翻倍实盘却亏得底朝天。这中间的差距绝大多数时候不是策略逻辑出了问题而是数据质量在背后埋了雷。我自己也是在吃了无数次亏之后才慢慢整理出一套从数据校验、清洗、预处理到回测验证的完整流程。这篇就把这套实践从头到尾拆开讲清楚希望能帮你少走点弯路。这里先强调一个核心观点K线数据不是拿来就能用的必须经过一整套质检和预处理流程才能进入策略计算和回测环节。数据校验、数据清洗、复权处理、衍生指标计算、前视偏差规避这些环节环环相扣任何一个地方出问题后面跑出来的结果都不可信。1. 拿到K线数据后先别急着算指标很多人下载完数据习惯性地先画个K线图看一眼“价格走势好像没问题”然后就开始写策略。这恰恰是最危险的做法。肉眼只能看到最明显的异常而量化回测对数据质量的敏感度远高于人眼。一根K线的开盘价错了、一个日期缺了、一个复权因子没处理都可能让最终回测结果产生百分之几十的偏差。1.1 数据校验的四个基本维度我把数据校验拆成四个维度做量化这四年每次拿到新数据源都会先过一遍这四个维度。第一是完整性。检查时间序列是否连续。这里要特别注意交易日历和自然日历的区别A股周末不开盘、节假日不开盘所以不能拿“自然日连续”去卡数据。要用交易所的交易日历看看预期交易日和实际交易日是否对得上。判断依据是tushare自带trade_cal接口AKShare也有交易日历获取方式。如果发现缺失要判断是停牌导致的合法缺失还是数据抓取失败导致的异常缺失。第二是连续性。连续性检查的核心是看前一条的收盘价和后一条的开盘价是否存在跳变。正常来说除权除息日前后价格会出现跳空这是合法的但如果在非除权日出现莫名其妙的跳空就要警惕数据错误。这里可以算一下相邻K线的跳空比例即abs(open[i] / close[i-1] - 1)超过阈值比如20%就需要人工复核。第三是正确性。检查OHLCOpen, High, Low, Close四价之间的逻辑关系。这是最容易被忽略但也是最好用的一类检查。真实有效的K线数据必须满足High是最高价、Low是最低价这个基本事实。也就是说high max(open, close)、low min(open, close)。如果出现High小于Close、Low大于Open这种数据说明数据源本身就有问题直接丢弃这段数据。第四是异常值。检查成交量、成交额是否为负数价格是否为0涨跌幅是否超出交易所限制等。另外还可以对收益率序列做分布检查看有没有极端离群值因为单根K线的数据错误会直接反映在收益率的异常跳跃上。1.2 用Python实现一次完整的数据体检下面这段代码是我每次拿到新数据源都会跑一遍的“体检脚本”。以pandas为核心工具配合交易日历做完整性判断。import pandas as pd import numpy as np def inspect_kline(df, trade_calNone): df: 必须包含字段 [date, open, high, low, close, volume] trade_cal: 交易日历列表格式为日期字符串列表用于完整性校验 df df.sort_values(date).reset_index(dropTrue) report {} # 1. 完整性检查 if trade_cal is not None: actual_dates set(df[date].astype(str)) expected_dates set(trade_cal) # 这里默认 trade_cal 是当前股票在对应时间段内的有效交易日 missing sorted(expected_dates - actual_dates) report[missing_dates] missing report[missing_count] len(missing) else: report[missing_dates] [] report[missing_count] 0 # 2. OHLC 逻辑关系检查 high_bad df[df[high] df[[open, close]].max(axis1)] low_bad df[df[low] df[[open, close]].min(axis1)] report[high_bad_count] len(high_bad) report[low_bad_count] len(low_bad) # 3. 异常值检查 report[negative_volume_count] (df[volume] 0).sum() report[zero_price_count] ((df[[open, high, low, close]] 0).sum().sum()) report[negative_price_count] ((df[[open, high, low, close]] 0).sum().sum()) # 4. 跳空检查 if len(df) 1: jump (df[open].iloc[1:] / df[close].iloc[:-1].replace(0, np.nan) - 1).abs() report[jump_over_19pct_count] (jump 0.19).sum() else: report[jump_over_19pct_count] 0 return report每拿到一批新数据先运行这个脚本生成报告再决定下一步是直接用、洗数据还是换数据源。这个习惯帮我挡掉了至少三次重大的回测事故。实测最常出问题的是两个high close这种逻辑错误以及缺失交易日导致的错误合并。注意完整性校验时不要把“非交易日”也算进缺失。很多人直接把自然日全部当成交易日结果在周末和节假日上报出大量“缺失”这是误报。正确做法是拿交易所官方交易日历做差集。2. 清洗与预处理让数据真正可用数据体检过了下一步就是清洗和预处理。这一步的目标是让数据集变得规整、一致、可用。具体来说包括缺失值处理、复权处理、索引规范化等。2.1 缺失值与停牌的处理策略数据缺失有两种情况处理方式完全不同。第一种是停牌导致的合法缺失。A股停牌期间没有交易K线数据源一般会直接跳过这些日期DataFrame里根本不会出现这些行。这种情况不需要填充但要注意在计算持有期收益或计算指标时你的数据索引是有空洞的不能简单用shift(1)去算“昨天的收益率”因为“昨天”在原始数据里可能是上一次交易日也可能是一个月前。正确处理是用pct_change()配合交易日期来保证对齐或者先把数据重采样到交易日历。第二种是临时抓取失败导致的异常缺失。比如网络波动导致某一天的数据没拿到。这种情况如果直接忽略后续计算周线、月线时会把相邻周期的数据错误连接起来导致周期K线出现假跳空。处理方式是先尝试重新拉取如果拉不到可以用前收盘价填充开盘价也就是假设平开并打上特殊标记在回测时明确这部分数据被填充过。2.2 复权绝大多数人踩过的最大的坑复权问题是K线处理里最经典也最容易被忽略的坑。我甚至见过所有数据都是前复权的结果但策略代码里居然混着“不复权”的除权日跳空导致回测时凭空出现一个50%的涨幅。先理清概念。股票发生分红、送股、配股时股价会除权除息比如10送10股价从20元变成10元。如果不复权K线图上就会出现一个“断崖式下跌”但这并不是真实亏损。不复权Raw保留真实成交价。优点是回测时能跟真实成交价格对上缺点是技术指标会被除权跳空严重干扰长期均线失真。前复权Forward Adjusted以当前价格为基准把历史上所有的价格进行缩放修正。优点是看历史走势很连续最适合做技术指标分析缺点是复权因子随时间变化历史数据会随“最新价格”不断变动不同的起始下载日期可能得到不同时间点的前复权价。后复权Backward Adjusted以上市首日为基准不随最新价格变动。优点是历史数据稳定不变适合做长期收益率计算缺点是最新价格不是真实成交价做交易信号计算时不直观。我的建议是分析K线形态、计算技术指标、画图展示用前复权数据做资金曲线、测算真实成交金额用不复权数据计算长期区间收益率用后复权数据。千万别图省事只准备一套数据跑到底。对于换仓频率不高的中低频策略最稳妥的组合是指标计算用前复权回测撮合用不复权并对除权日做单独处理。2.3 数据对齐与索引规范化多标的数据是量化里的常态。你在选股时要对比几十只股票这时数据的齐整性就很关键。常见的问题是不同股票的上市时间不同中途还有停牌导致它们的时间索引长度不一样。标准的做法是用pivot整形成宽表即每一行是一个交易日、每一列是一只股票的收盘价然后对全市场时间索引做并集缺失的填NaN。注意“并行计算收益率”时pct_change()会自动跳过NaN但在fillna(methodffill)时要想清楚——是用“前值填充”代表“停牌期间默认持有”还是用NaN直接跳过。这个决策直接影响策略的持仓判断务必先明确意图再操作。# 多股票价格对齐示例 price_df df.pivot_table(indexdate, columnsts_code, valuesclose) # 按交易日排序 price_df price_df.sort_index() # 收益率计算会自动忽略 NaN ret_df price_df.pct_change()3. 衍生指标计算从K线到可用的交易信号数据规整好了接下来就是算指标。这个环节同样有大量细节不是套一个talib函数就完事的。3.1 收益率计算与对数收益率的选用计算收益率是最基本的操作但用pct_change()前需要确认数据没有未来函数干扰。普通收益率即close[t] / close[t-1] - 1对数收益率为log(close[t] / close[t-1])。两者在短周期上差别不大但长周期累积时有区别。我在实际使用中的经验是做单标的、时间序列模型比如GARCH时偏向用对数收益率因为对数收益率的可加性让多期收益率的计算变成简单求和且统计性质更好做组合回测、资金曲线时用普通收益率更直观因为组合收益率的计算本身就是按权重对普通收益率做加权平均这里不能用对数收益率。还有一个非常隐蔽的坑用pct_change()计算收益率时如果前收盘是NaN后面所有依赖该收益率的指标如夏普比率、最大回撤都会在起始段出现异常。正确做法是在计算后截断前几天的NaN区间或者在回测起点之前预留足够的历史数据用于指标预热。3.2 常用技术指标的实现与边界处理以最简单的均线MA为例很多人直接close.rolling(20).mean()完事。但真正做策略时需要考虑这么几个问题第一个问题是数据起点。一只股票上市前100天20日均线是算不出来的。如果你把这段数据直接丢掉回测起步就被压缩了如果你不丢指标前面会有大量NaN一些回测框架会自动跳过这些交易日导致没有足够的历史数据来计算信号。解决办法是在回测起始日之前预留至少max(所有指标窗口)长度的数据用于预热。比如策略用20日均线和60日均线那至少预留60天以上的历史数据才能从第一天开始正确生成信号。第二个问题是指标计算用前复权还是不复权。前面说过技术指标用前复权更合理因为除权跳空会严重扭曲均线、RSI、布林带等指标。比如一个10送10的股票不复权价格从20跳到1060日均线会被瞬间拉低产生虚假的“超卖”信号。用前复权数据则可以避免这个问题。第三个问题是滚动窗口的边界。比如布林带计算时窗口内少于多少根K线时应该输出NaN而不是硬算防止信号畸形。另外RSI计算时如果连续几天涨跌幅为0也要做平滑处理否则会出现除零错误。下面我给一个简单但完整的指标计算示例包含MA、RSI、ATR三种并处理了前视偏差和边界问题def add_indicators(df, ma_short5, ma_long20, rsi_period14): df df.copy() # 用前复权数据计算指标 close df[close] # 均线 — 注意窗口不足时为 NaN df[ma_short] close.rolling(ma_short, min_periodsma_short).mean() df[ma_long] close.rolling(ma_long, min_periodsma_long).mean() # RSI — Wilder 平滑算法避免涨跌幅全为0时除零 delta close.diff() gain delta.clip(lower0) loss -delta.clip(upper0) avg_gain gain.ewm(alpha1/rsi_period, min_periodsrsi_period).mean() avg_loss loss.ewm(alpha1/rsi_period, min_periodsrsi_period).mean() rs avg_gain / avg_loss.replace(0, np.nan) df[rsi] 100 - 100 / (1 rs) df[rsi] df[rsi].fillna(100) # 若 avg_loss 为 0则 RSI 视为 100 # ATR — 真实波幅 prev_close close.shift(1) tr pd.concat([ df[high] - df[low], (df[high] - prev_close).abs(), (df[low] - prev_close).abs() ], axis1).max(axis1) df[atr] tr.ewm(alpha1/rsi_period, min_periodsrsi_period).mean() return df3.3 计算逻辑的前视偏差排查前视偏差Look-ahead Bias是回测中最隐蔽的错误之一。简单说就是在信号计算时“偷看”了未来数据。最常见的一种是在计算第t天的指标时误用了t天收盘之后才产生的数据。举个例子你想根据“今天收盘价站上20日均线”来买入。如果代码写成close[t] close.rolling(20).mean()且close[t]是当日收盘价这在日线策略中是合理的。但如果你先对整个DataFrame做了shift(-1)把明天的数据挪到了今天然后算信号这就变成了未来函数。更隐蔽的是指标本身包含未来数据。有些人在pandas里用rolling(window).mean()时如果window计算包含close[t1]就相当于用了未来数据。排查方法不算复杂把指标结果逐行打印出来对照K线图手工验证最后一天和最后第二天的信号值确认信号生成当天用的是否是当天及之前的数据。注意rolling默认是向后窗口即包含当前值和过去值不会偷看未来。但如果你用了shift(-1)、rolling(centerTrue)、或在循环里错误地使用了整列数据就极容易引入未来数据。回测时可以用一个最简单的方法验证把最后一根K线的数据改成极端值看信号是否会发生变化。如果信号变了说明代码引用了未来数据。4. 量化回测中的数据质量陷阱数据清洗和指标计算做完了终于可以跑回测了。但回测本身依旧有一堆数据质量陷阱处理不好回测结果是没有任何参考价值的。4.1 未来函数与信号偏移回测最大的隐形杀手除了指标计算里的前视偏差回测撮合时最容易出的问题是“信号偏移”。典型场景是日线策略在t日收盘时产生买入信号但回测框架在t日开盘价就成交了甚至更夸张地用t1日的最低价成交收益凭空高出一大截。正确的做法是信号在t日收盘后产生成交价只能是t1日及之后的交易价格。对于日线策略最保守且最贴近实盘的是t1日开盘价成交。如果你用的是t日收盘价成交那就必须假设收盘价是信号产生的瞬间就能成交这在小资金、高流动性标的上勉强能用但在大资金或流动性差的标的上会严重失真。4.2 幸存者偏差与股票池处理幸存者偏差是指你在回测时只用了“现在还活着”的股票那些退市、暴跌的股票被排除在外导致回测结果虚高。这个问题在选股类策略里特别致命。处理方式是在初始股票池里就包含历史上曾经存在的股票包括后来退市的。如果数据源里没有退市股票的数据就通过指数成份股的历史快照或者用全市场列表来构建股票池。复盘时至少要做到股票池是“当时时点”存在的股票集合而不是“今天”还上市的股票集合。另外做选股策略时数据对齐后要保留“全市场横截面”而不是只保留有完整数据的股票。因为那些缺失数据的股票很可能就是停牌的、出问题的股票剔除它们本身就是一种潜在偏差。4.3 交易成本、滑点与涨跌停约束回测里如果没有交易成本和滑点那基本就是自欺欺人。A股双边手续费加印花税加上冲击成本交易成本通常至少要按单边千分之一到千分之二估算高频策略还得更高。滑点的设置要结合K线的实际流动性和波动率。一个比较实用的做法是用ATR平均真实波幅来估算滑点比如单边滑点设为0.1 * ATR。对于日线策略这个量级能较好地模拟出实际成交价格与信号价格的偏差。还有涨跌停约束。如果信号要求买入的股票当天一字涨停你大概率买不进如果要求卖出的股票一字跌停你也卖不出。回测框架如果没有这个约束策略在极端行情下会给出虚假的完美交易。我自己用的处理方式在数据里加入limit_up和limit_down标记可以用close high且接近涨停幅度判断或者用官方涨跌停价格判断撮合时对一字涨停禁止买入、对一字跌停禁止卖出。# 简单判断涨跌停以沪深主板 10% 为例 limit_pct 0.1 df[limit_up] (df[close] df[pre_close] * (1 limit_pct) - 0.001) (df[high] df[low]) df[limit_down] (df[close] df[pre_close] * (1 - limit_pct) 0.001) (df[high] df[low])注意这里用了pre_close也就是前收盘价。在除权除息日涨跌停的基准价是调整后的前收盘不是简单用前一天收盘价所以数据里必须保留pre_close字段。4.4 样本内外的划分与过拟合控制数据质量不光是数据本身还涉及怎么用数据。很多人在回测时把所有数据都用来调参找到一个在历史上收益漂亮的参数组合跑出来的结果当然好看但一到实盘就崩。这是典型的过拟合。正确的做法是把数据按时间切分为样本内In-Sample和样本外Out-of-Sample比如前70%用来开发策略和调整参数后30%用来验证。也可以使用Walk-Forward方法滚动地先训练再测试确保每段测试数据都未参与过参数优化。一个额外的经验换手率越高的策略对数据质量越敏感。如果你发现一个策略参数稍微一动收益就从年化50%掉到负数这基本可以断定数据或策略中包含了某种噪声实际执行时大概率不如回测理想。5. 常见问题排查速查最后整理一个我在实际处理K线数据时经常碰到的排查表方便你遇到问题直接对照。现象可能原因处理方式K线图出现莫名大跳空除权除息未复权或数据存在缺失确认是否除权日若是用复权数据若否则检查数据源均线在某个区间明显扭曲使用了不复权数据算指标指标计算切换为前复权数据收益率在数据开头出现极端值pct_change()计算时存在NaN或除零截断前段NaN预留预热期回测在涨停板买入成功未设置涨跌停约束增加一字涨跌停的成交限制回测收益极高但实盘完全不涨存在未来函数或幸存者偏差用末尾K线极端值测试信号检查股票池是否包含退市股票同一策略不同时间下载数据结果差异大前复权数据随最新价变化统一用后复权做评估或固定数据下载日期多股票数据处理后索引错位未对时间索引做并集或未排序用pivot后sort_index明确处理NaN策略还有一个细节值得单独提一下浮点精度问题。尤其是在计算复权价、收益率、指标时浮点累计误差可能导致close high这种判断出现意外失败。我一般在比较时都会加上一个容差比如abs(a - b) 1e-6或者直接对价格做round(2)处理避免因为浮点数导致逻辑判断出错。另外如果数据源返回的是字符串类型的日期记得统一转成datetime64不然排序和切片会得到诡异的结果。比如2024-01-15是字符串它跟2024-1-5排序出来完全是两个世界。我自己的习惯是在数据加载后立即执行一个统一的类型转换和排序然后再做校验df[date] pd.to_datetime(df[date]) df df.sort_values(date).reset_index(dropTrue)这行代码不复杂但能避免掉后面不少莫名其妙的bug。最后再分享一个小技巧处理K线数据时我会把“数据校验报告”和“指标计算日志”一并保存下来每次回测跑完强制自己看一眼校验报告里有没有新增异常。这样即使后来换了数据源、改了字段也能第一时间发现数据接口变动对结果造成的影响。这个方法帮我在一次数据源无声升级字段精度时提前发现了回测结果的异常偏移避免了上线一套错误策略的囧境。
返回列表