news 2026/9/23 23:33:58

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
量化回测前必做: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%)就需要人工复核。

第三是正确性。检查OHLC(Open, 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_cal=None): """ df: 必须包含字段 [date, open, high, low, close, volume] trade_cal: 交易日历列表,格式为日期字符串列表,用于完整性校验 """ df = df.sort_values('date').reset_index(drop=True) 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(axis=1)] low_bad = df[df['low'] > df[['open', 'close']].min(axis=1)] 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(method='ffill')时要想清楚——是用“前值填充”代表“停牌期间默认持有”,还是用NaN直接跳过。这个决策直接影响策略的持仓判断,务必先明确意图再操作。

# 多股票价格对齐示例 price_df = df.pivot_table(index='date', columns='ts_code', values='close') # 按交易日排序 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跳到10,60日均线会被瞬间拉低,产生虚假的“超卖”信号。用前复权数据则可以避免这个问题。

第三个问题是滚动窗口的边界。比如布林带计算时,窗口内少于多少根K线时应该输出NaN而不是硬算,防止信号畸形。另外RSI计算时,如果连续几天涨跌幅为0,也要做平滑处理,否则会出现除零错误。

下面我给一个简单但完整的指标计算示例,包含MA、RSI、ATR三种,并处理了前视偏差和边界问题:

def add_indicators(df, ma_short=5, ma_long=20, rsi_period=14): df = df.copy() # 用前复权数据计算指标 close = df['close'] # 均线 — 注意窗口不足时为 NaN df['ma_short'] = close.rolling(ma_short, min_periods=ma_short).mean() df['ma_long'] = close.rolling(ma_long, min_periods=ma_long).mean() # RSI — Wilder 平滑算法,避免涨跌幅全为0时除零 delta = close.diff() gain = delta.clip(lower=0) loss = -delta.clip(upper=0) avg_gain = gain.ewm(alpha=1/rsi_period, min_periods=rsi_period).mean() avg_loss = loss.ewm(alpha=1/rsi_period, min_periods=rsi_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() ], axis=1).max(axis=1) df['atr'] = tr.ewm(alpha=1/rsi_period, min_periods=rsi_period).mean() return df

3.3 计算逻辑的前视偏差排查

前视偏差(Look-ahead Bias)是回测中最隐蔽的错误之一。简单说,就是在信号计算时“偷看”了未来数据。最常见的一种是在计算第t天的指标时,误用了t天收盘之后才产生的数据。

举个例子,你想根据“今天收盘价站上20日均线”来买入。如果代码写成close[t] > close.rolling(20).mean()close[t]是当日收盘价,这在日线策略中是合理的。但如果你先对整个DataFrame做了shift(-1),把明天的数据挪到了今天,然后算信号,这就变成了未来函数。

更隐蔽的是指标本身包含未来数据。有些人在pandas里用rolling(window).mean()时,如果window计算包含close[t+1],就相当于用了未来数据。排查方法不算复杂:把指标结果逐行打印出来,对照K线图手工验证最后一天和最后第二天的信号值,确认信号生成当天用的是否是当天及之前的数据。

注意:rolling默认是向后窗口,即包含当前值和过去值,不会偷看未来。但如果你用了shift(-1)rolling(center=True)、或在循环里错误地使用了整列数据,就极容易引入未来数据。回测时可以用一个最简单的方法验证:把最后一根K线的数据改成极端值,看信号是否会发生变化。如果信号变了,说明代码引用了未来数据。

4. 量化回测中的数据质量陷阱

数据清洗和指标计算做完了,终于可以跑回测了。但回测本身依旧有一堆数据质量陷阱,处理不好,回测结果是没有任何参考价值的。

4.1 未来函数与信号偏移:回测最大的隐形杀手

除了指标计算里的前视偏差,回测撮合时最容易出的问题是“信号偏移”。典型场景是:日线策略在t日收盘时产生买入信号,但回测框架在t日开盘价就成交了,甚至更夸张地用t+1日的最低价成交,收益凭空高出一大截。

正确的做法是:信号在t日收盘后产生,成交价只能是t+1日及之后的交易价格。对于日线策略,最保守且最贴近实盘的是t+1日开盘价成交。如果你用的是t日收盘价成交,那就必须假设收盘价是信号产生的瞬间就能成交,这在小资金、高流动性标的上勉强能用,但在大资金或流动性差的标的上会严重失真。

4.2 幸存者偏差与股票池处理

幸存者偏差是指你在回测时只用了“现在还活着”的股票,那些退市、暴跌的股票被排除在外,导致回测结果虚高。这个问题在选股类策略里特别致命。

处理方式是在初始股票池里就包含历史上曾经存在的股票,包括后来退市的。如果数据源里没有退市股票的数据,就通过指数成份股的历史快照,或者用全市场列表来构建股票池。复盘时至少要做到:股票池是“当时时点”存在的股票集合,而不是“今天”还上市的股票集合。

另外,做选股策略时,数据对齐后要保留“全市场横截面”,而不是只保留有完整数据的股票。因为那些缺失数据的股票很可能就是停牌的、出问题的股票,剔除它们本身就是一种潜在偏差。

4.3 交易成本、滑点与涨跌停约束

回测里如果没有交易成本和滑点,那基本就是自欺欺人。A股双边手续费加印花税,加上冲击成本,交易成本通常至少要按单边千分之一到千分之二估算,高频策略还得更高。

滑点的设置要结合K线的实际流动性和波动率。一个比较实用的做法是用ATR(平均真实波幅)来估算滑点,比如单边滑点设为0.1 * ATR。对于日线策略,这个量级能较好地模拟出实际成交价格与信号价格的偏差。

还有涨跌停约束。如果信号要求买入的股票当天一字涨停,你大概率买不进;如果要求卖出的股票一字跌停,你也卖不出。回测框架如果没有这个约束,策略在极端行情下会给出虚假的完美交易。我自己用的处理方式:在数据里加入limit_uplimit_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(drop=True)

这行代码不复杂,但能避免掉后面不少莫名其妙的bug。

最后再分享一个小技巧:处理K线数据时,我会把“数据校验报告”和“指标计算日志”一并保存下来,每次回测跑完,强制自己看一眼校验报告里有没有新增异常。这样即使后来换了数据源、改了字段,也能第一时间发现数据接口变动对结果造成的影响。这个方法帮我在一次数据源无声升级字段精度时,提前发现了回测结果的异常偏移,避免了上线一套错误策略的囧境。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/23 23:29:04

CAIL2019相似案例匹配第二名方案详解:从数据清洗到BERT双塔精排

简介&#xff1a;法研杯2019相似案例匹配第二名解决方案&#xff0c;内含CAIL2020/2021司法考试赛道冠军团队代码与文档&#xff0c;面向法律NLP、机器学习及司法AI方向的开发者和参赛者&#xff0c;直击法律文本相似度匹配这一典型场景。压缩包共22个文件&#xff0c;包含6个P…

作者头像 李华
网站建设 2026/9/23 23:22:22

PRQL 的 Elixir 绑定:使用 Rustler NIF 在 Elixir 中编译 PRQL 查询

后端 【免费下载链接】prql PRQL is a modern language for transforming data — a simple, powerful, pipelined SQL replacement 项目地址&#xff1a; https://gitcode.com/gh_mirrors/pr/prql 点击查看 免费下载 本指南围绕 PRQL 仓库中的 Elixir 语言绑定&#xff08;位…

作者头像 李华
网站建设 2026/9/23 23:17:04

黑翅鸢算法优化CNN-BiLSTM-Attention的客流量预测实战

简介&#xff1a;这是一份基于黑翅鸢算法BKA-CNN-BiLSTM-Attention的客流量预测Matlab实现&#xff0c;面向计算机、电子信息工程、数学等专业的学生&#xff0c;可用于课程设计、期末大作业与毕业设计。代码采用参数化编程&#xff0c;注释清晰&#xff0c;附赠可直接运行的案…

作者头像 李华